Merge branch 'voice' into 'master'

Voice: Basics

See merge request litecord/litecord!24
This commit is contained in:
Luna 2019-03-09 01:59:22 +00:00
commit fa73e90170
40 changed files with 1897 additions and 319 deletions

View File

@ -19,6 +19,7 @@ aiohttp = "==3.4.4"
pytest = "==3.10.1" pytest = "==3.10.1"
pytest-asyncio = "==0.9.0" pytest-asyncio = "==0.9.0"
pyflakes = "*" pyflakes = "*"
mypy = "*"
[requires] [requires]
python_version = "3.7" python_version = "3.7"

332
Pipfile.lock generated
View File

@ -1,7 +1,7 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "b546ad1edfe79457cb4da95e19fd17506b7adabe6a43acbc0906fb12cfda68b2" "sha256": "4332c948a4bf656d0b95f4753eed6a3793dcb5ee9cd3538a443d7094a584e6cd"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": { "requires": {
@ -82,10 +82,10 @@
}, },
"attrs": { "attrs": {
"hashes": [ "hashes": [
"sha256:10cbf6e27dbce8c30807caf056c8eb50917e0eaafe86347671b57254006c3e69", "sha256:69c0dbf2ed392de1cb5ec704444b08a5ef81680a61cb899dc08127123af36a79",
"sha256:ca4be454458f9dec299268d472aaa5a11f67a4ff70093396e1ceae9c76cf4bbb" "sha256:f0b870f674851ecbfbbbd364d6b5cbdff9dcedbc7f3f5e18a6891057f21fe399"
], ],
"version": "==18.2.0" "version": "==19.1.0"
}, },
"bcrypt": { "bcrypt": {
"hashes": [ "hashes": [
@ -142,40 +142,36 @@
}, },
"cffi": { "cffi": {
"hashes": [ "hashes": [
"sha256:151b7eefd035c56b2b2e1eb9963c90c6302dc15fbd8c1c0a83a163ff2c7d7743", "sha256:00b97afa72c233495560a0793cdc86c2571721b4271c0667addc83c417f3d90f",
"sha256:1553d1e99f035ace1c0544050622b7bc963374a00c467edafac50ad7bd276aef", "sha256:0ba1b0c90f2124459f6966a10c03794082a2f3985cd699d7d63c4a8dae113e11",
"sha256:1b0493c091a1898f1136e3f4f991a784437fac3673780ff9de3bcf46c80b6b50", "sha256:0bffb69da295a4fc3349f2ec7cbe16b8ba057b0a593a92cbe8396e535244ee9d",
"sha256:2ba8a45822b7aee805ab49abfe7eec16b90587f7f26df20c71dd89e45a97076f", "sha256:21469a2b1082088d11ccd79dd84157ba42d940064abbfa59cf5f024c19cf4891",
"sha256:3bb6bd7266598f318063e584378b8e27c67de998a43362e8fce664c54ee52d30", "sha256:2e4812f7fa984bf1ab253a40f1f4391b604f7fc424a3e21f7de542a7f8f7aedf",
"sha256:3c85641778460581c42924384f5e68076d724ceac0f267d66c757f7535069c93", "sha256:2eac2cdd07b9049dd4e68449b90d3ef1adc7c759463af5beb53a84f1db62e36c",
"sha256:3eb6434197633b7748cea30bf0ba9f66727cdce45117a712b29a443943733257", "sha256:2f9089979d7456c74d21303c7851f158833d48fb265876923edcb2d0194104ed",
"sha256:495c5c2d43bf6cebe0178eb3e88f9c4aa48d8934aa6e3cddb865c058da76756b", "sha256:3dd13feff00bddb0bd2d650cdb7338f815c1789a91a6f68fdc00e5c5ed40329b",
"sha256:4c91af6e967c2015729d3e69c2e51d92f9898c330d6a851bf8f121236f3defd3", "sha256:4065c32b52f4b142f417af6f33a5024edc1336aa845b9d5a8d86071f6fcaac5a",
"sha256:57b2533356cb2d8fac1555815929f7f5f14d68ac77b085d2326b571310f34f6e", "sha256:51a4ba1256e9003a3acf508e3b4f4661bebd015b8180cc31849da222426ef585",
"sha256:770f3782b31f50b68627e22f91cb182c48c47c02eb405fd689472aa7b7aa16dc", "sha256:59888faac06403767c0cf8cfb3f4a777b2939b1fbd9f729299b5384f097f05ea",
"sha256:79f9b6f7c46ae1f8ded75f68cf8ad50e5729ed4d590c74840471fc2823457d04", "sha256:59c87886640574d8b14910840327f5cd15954e26ed0bbd4e7cef95fa5aef218f",
"sha256:7a33145e04d44ce95bcd71e522b478d282ad0eafaf34fe1ec5bbd73e662f22b6", "sha256:610fc7d6db6c56a244c2701575f6851461753c60f73f2de89c79bbf1cc807f33",
"sha256:857959354ae3a6fa3da6651b966d13b0a8bed6bbc87a0de7b38a549db1d2a359", "sha256:70aeadeecb281ea901bf4230c6222af0248c41044d6f57401a614ea59d96d145",
"sha256:87f37fe5130574ff76c17cab61e7d2538a16f843bb7bca8ebbc4b12de3078596", "sha256:71e1296d5e66c59cd2c0f2d72dc476d42afe02aeddc833d8e05630a0551dad7a",
"sha256:95d5251e4b5ca00061f9d9f3d6fe537247e145a8524ae9fd30a2f8fbce993b5b", "sha256:8fc7a49b440ea752cfdf1d51a586fd08d395ff7a5d555dc69e84b1939f7ddee3",
"sha256:9d1d3e63a4afdc29bd76ce6aa9d58c771cd1599fbba8cf5057e7860b203710dd", "sha256:9b5c2afd2d6e3771d516045a6cfa11a8da9a60e3d128746a7fe9ab36dfe7221f",
"sha256:a36c5c154f9d42ec176e6e620cb0dd275744aa1d804786a71ac37dc3661a5e95", "sha256:9c759051ebcb244d9d55ee791259ddd158188d15adee3c152502d3b69005e6bd",
"sha256:a6a5cb8809091ec9ac03edde9304b3ad82ad4466333432b16d78ef40e0cce0d5", "sha256:b4d1011fec5ec12aa7cc10c05a2f2f12dfa0adfe958e56ae38dc140614035804",
"sha256:ae5e35a2c189d397b91034642cb0eab0e346f776ec2eb44a49a459e6615d6e2e", "sha256:b4f1d6332339ecc61275bebd1f7b674098a66fea11a00c84d1c58851e618dc0d",
"sha256:b0f7d4a3df8f06cf49f9f121bead236e328074de6449866515cea4907bbc63d6", "sha256:c030cda3dc8e62b814831faa4eb93dd9a46498af8cd1d5c178c2de856972fd92",
"sha256:b75110fb114fa366b29a027d0c9be3709579602ae111ff61674d28c93606acca", "sha256:c2e1f2012e56d61390c0e668c20c4fb0ae667c44d6f6a2eeea5d7148dcd3df9f",
"sha256:ba5e697569f84b13640c9e193170e89c13c6244c24400fc57e88724ef610cd31", "sha256:c37c77d6562074452120fc6c02ad86ec928f5710fbc435a181d69334b4de1d84",
"sha256:be2a9b390f77fd7676d80bc3cdc4f8edb940d8c198ed2d8c0be1319018c778e1", "sha256:c8149780c60f8fd02752d0429246088c6c04e234b895c4a42e1ea9b4de8d27fb",
"sha256:ca1bd81f40adc59011f58159e4aa6445fc585a32bb8ac9badf7a2c1aa23822f2", "sha256:cbeeef1dc3c4299bd746b774f019de9e4672f7cc666c777cd5b409f0b746dac7",
"sha256:d5d8555d9bfc3f02385c1c37e9f998e2011f0db4f90e250e5bc0c0a85a813085", "sha256:e113878a446c6228669144ae8a56e268c91b7f1fafae927adc4879d9849e0ea7",
"sha256:e55e22ac0a30023426564b1059b035973ec82186ddddbac867078435801c7801", "sha256:e21162bf941b85c0cda08224dade5def9360f53b09f9f259adb85fc7dd0e7b35",
"sha256:e90f17980e6ab0f3c2f3730e56d1fe9bcba1891eeea58966e89d352492cc74f4", "sha256:fb6934ef4744becbda3143d30c6604718871495a5e36c408431bf33d9c146889"
"sha256:ecbb7b01409e9b782df5ded849c178a0aa7c906cf8c5a67368047daab282b184",
"sha256:ed01918d545a38998bfa5902c7c00e0fee90e957ce036a4000a88e3fe2264917",
"sha256:edabd457cd23a02965166026fd9bfd196f4324fe6032e866d0f3bd0301cd486f",
"sha256:fdf1c1dc5bafc32bc5d08b054f94d659422b05aba244d6be4ddc1c72d9aa70fb"
], ],
"version": "==1.11.5" "version": "==1.12.2"
}, },
"chardet": { "chardet": {
"hashes": [ "hashes": [
@ -213,10 +209,10 @@
}, },
"h2": { "h2": {
"hashes": [ "hashes": [
"sha256:4be613e35caad5680dc48f98f3bf4e7338c7c429e6375a5137be7fbe45219981", "sha256:c8f387e0e4878904d4978cd688a3195f6b169d49b1ffa572a3d347d7adc5e09f",
"sha256:b2962f883fa392a23cbfcc4ad03c335bcc661be0cf9627657b589f0df2206e64" "sha256:fd07e865a3272ac6ef195d8904de92dc7b38dc28297ec39cfa22716b6d62e6eb"
], ],
"version": "==3.0.1" "version": "==3.1.0"
}, },
"hpack": { "hpack": {
"hashes": [ "hashes": [
@ -227,17 +223,17 @@
}, },
"hypercorn": { "hypercorn": {
"hashes": [ "hashes": [
"sha256:3931144309c40341a46a2d054ac550bbd012a1f1a803774b5d6a3add90f52259", "sha256:97bad5887ff543e2dff0a584d1a084e702789a26df5c8fb027ac1efab32274c5",
"sha256:4df03fbc101efb4faf0b0883863ff7e620f94310e309311ceafaadb38ee1fa36" "sha256:b90799a1bc84f00ee999071e259f194087881b85c7240994ba9d86c4ceff3305"
], ],
"version": "==0.4.2" "version": "==0.5.3"
}, },
"hyperframe": { "hyperframe": {
"hashes": [ "hashes": [
"sha256:87567c9eb1540de1e7f48805adf00e87856409342fdebd0cd20cf5d381c38b69", "sha256:5187962cb16dcc078f23cb5a4b110098d546c3f41ff2d4038a9896893bbd0b40",
"sha256:a25944539db36d6a2e47689e7915dcee562b3f8d10c6cdfa0d53c91ed692fb04" "sha256:a9f5c17f2cc3c719b917c4f33ed1c61bd1f8dfac4b1bd23b7c80b3400971b41f"
], ],
"version": "==5.1.0" "version": "==5.2.0"
}, },
"idna": { "idna": {
"hashes": [ "hashes": [
@ -273,36 +269,36 @@
}, },
"markupsafe": { "markupsafe": {
"hashes": [ "hashes": [
"sha256:048ef924c1623740e70204aa7143ec592504045ae4429b59c30054cb31e3c432", "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473",
"sha256:130f844e7f5bdd8e9f3f42e7102ef1d49b2e6fdf0d7526df3f87281a532d8c8b", "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161",
"sha256:19f637c2ac5ae9da8bfd98cef74d64b7e1bb8a63038a3505cd182c3fac5eb4d9", "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235",
"sha256:1b8a7a87ad1b92bd887568ce54b23565f3fd7018c4180136e1cf412b405a47af", "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5",
"sha256:1c25694ca680b6919de53a4bb3bdd0602beafc63ff001fea2f2fc16ec3a11834", "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff",
"sha256:1f19ef5d3908110e1e891deefb5586aae1b49a7440db952454b4e281b41620cd", "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b",
"sha256:1fa6058938190ebe8290e5cae6c351e14e7bb44505c4a7624555ce57fbbeba0d", "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1",
"sha256:31cbb1359e8c25f9f48e156e59e2eaad51cd5242c05ed18a8de6dbe85184e4b7", "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e",
"sha256:3e835d8841ae7863f64e40e19477f7eb398674da6a47f09871673742531e6f4b", "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183",
"sha256:4e97332c9ce444b0c2c38dd22ddc61c743eb208d916e4265a2a3b575bdccb1d3", "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66",
"sha256:525396ee324ee2da82919f2ee9c9e73b012f23e7640131dd1b53a90206a0f09c", "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1",
"sha256:52b07fbc32032c21ad4ab060fec137b76eb804c4b9a1c7c7dc562549306afad2", "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1",
"sha256:52ccb45e77a1085ec5461cde794e1aa037df79f473cbc69b974e73940655c8d7", "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e",
"sha256:5c3fbebd7de20ce93103cb3183b47671f2885307df4a17a0ad56a1dd51273d36", "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b",
"sha256:5e5851969aea17660e55f6a3be00037a25b96a9b44d2083651812c99d53b14d1", "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905",
"sha256:5edfa27b2d3eefa2210fb2f5d539fbed81722b49f083b2c6566455eb7422fd7e", "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735",
"sha256:7d263e5770efddf465a9e31b78362d84d015cc894ca2c131901a4445eaa61ee1", "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d",
"sha256:83381342bfc22b3c8c06f2dd93a505413888694302de25add756254beee8449c", "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e",
"sha256:857eebb2c1dc60e4219ec8e98dfa19553dae33608237e107db9c6078b1167856", "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d",
"sha256:98e439297f78fca3a6169fd330fbe88d78b3bb72f967ad9961bcac0d7fdd1550", "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c",
"sha256:bf54103892a83c64db58125b3f2a43df6d2cb2d28889f14c78519394feb41492", "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21",
"sha256:d9ac82be533394d341b41d78aca7ed0e0f4ba5a2231602e2f05aa87f25c51672", "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2",
"sha256:e982fe07ede9fada6ff6705af70514a52beb1b2c3d25d4e873e82114cf3c5401", "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5",
"sha256:edce2ea7f3dfc981c4ddc97add8a61381d9642dc3273737e756517cc03e84dd6", "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b",
"sha256:efdc45ef1afc238db84cb4963aa689c0408912a0239b0721cb172b4016eb31d6", "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6",
"sha256:f137c02498f8b935892d5c0172560d7ab54bc45039de8805075e19079c639a9c", "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f",
"sha256:f82e347a72f955b7017a39708a3667f106e6ad4d10b25f237396a7115d8ed5fd", "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f",
"sha256:fb7c206e01ad85ce57feeaaa0bf784b97fa3cad0d4a5737bc5295785f5c613a1" "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7"
], ],
"version": "==1.1.0" "version": "==1.1.1"
}, },
"multidict": { "multidict": {
"hashes": [ "hashes": [
@ -340,39 +336,39 @@
}, },
"pillow": { "pillow": {
"hashes": [ "hashes": [
"sha256:00203f406818c3f45d47bb8fe7e67d3feddb8dcbbd45a289a1de7dd789226360", "sha256:051de330a06c99d6f84bcf582960487835bcae3fc99365185dc2d4f65a390c0e",
"sha256:0616f800f348664e694dddb0b0c88d26761dd5e9f34e1ed7b7a7d2da14b40cb7", "sha256:0ae5289948c5e0a16574750021bd8be921c27d4e3527800dc9c2c1d2abc81bf7",
"sha256:1f7908aab90c92ad85af9d2fec5fc79456a89b3adcc26314d2cde0e238bd789e", "sha256:0b1efce03619cdbf8bcc61cfae81fcda59249a469f31c6735ea59badd4a6f58a",
"sha256:2ea3517cd5779843de8a759c2349a3cd8d3893e03ab47053b66d5ec6f8bc4f93", "sha256:163136e09bd1d6c6c6026b0a662976e86c58b932b964f255ff384ecc8c3cefa3",
"sha256:48a9f0538c91fc136b3a576bee0e7cd174773dc9920b310c21dcb5519722e82c", "sha256:18e912a6ccddf28defa196bd2021fe33600cbe5da1aa2f2e2c6df15f720b73d1",
"sha256:5280ebc42641a1283b7b1f2c20e5b936692198b9dd9995527c18b794850be1a8", "sha256:24ec3dea52339a610d34401d2d53d0fb3c7fd08e34b20c95d2ad3973193591f1",
"sha256:5e34e4b5764af65551647f5cc67cf5198c1d05621781d5173b342e5e55bf023b", "sha256:267f8e4c0a1d7e36e97c6a604f5b03ef58e2b81c1becb4fccecddcb37e063cc7",
"sha256:63b120421ab85cad909792583f83b6ca3584610c2fe70751e23f606a3c2e87f0", "sha256:3273a28734175feebbe4d0a4cde04d4ed20f620b9b506d26f44379d3c72304e1",
"sha256:696b5e0109fe368d0057f484e2e91717b49a03f1e310f857f133a4acec9f91dd", "sha256:4c678e23006798fc8b6f4cef2eaad267d53ff4c1779bd1af8725cc11b72a63f3",
"sha256:870ed021a42b1b02b5fe4a739ea735f671a84128c0a666c705db2cb9abd528eb", "sha256:4d4bc2e6bb6861103ea4655d6b6f67af8e5336e7216e20fff3e18ffa95d7a055",
"sha256:916da1c19e4012d06a372127d7140dae894806fad67ef44330e5600d77833581", "sha256:505738076350a337c1740a31646e1de09a164c62c07db3b996abdc0f9d2e50cf",
"sha256:9303a289fa0811e1c6abd9ddebfc770556d7c3311cb2b32eff72164ddc49bc64", "sha256:5233664eadfa342c639b9b9977190d64ad7aca4edc51a966394d7e08e7f38a9f",
"sha256:9577888ecc0ad7d06c3746afaba339c94d62b59da16f7a5d1cff9e491f23dace", "sha256:5d95cb9f6cced2628f3e4de7e795e98b2659dfcc7176ab4a01a8b48c2c2f488f",
"sha256:987e1c94a33c93d9b209315bfda9faa54b8edfce6438a1e93ae866ba20de5956", "sha256:7eda4c737637af74bac4b23aa82ea6fbb19002552be85f0b89bc27e3a762d239",
"sha256:99a3bbdbb844f4fb5d6dd59fac836a40749781c1fa63c563bc216c27aef63f60", "sha256:801ddaa69659b36abf4694fed5aa9f61d1ecf2daaa6c92541bbbbb775d97b9fe",
"sha256:99db8dc3097ceafbcff9cb2bff384b974795edeb11d167d391a02c7bfeeb6e16", "sha256:825aa6d222ce2c2b90d34a0ea31914e141a85edefc07e17342f1d2fdf121c07c",
"sha256:a5a96cf49eb580756a44ecf12949e52f211e20bffbf5a95760ac14b1e499cd37", "sha256:9c215442ff8249d41ff58700e91ef61d74f47dfd431a50253e1a1ca9436b0697",
"sha256:aa6ca3eb56704cdc0d876fc6047ffd5ee960caad52452fbee0f99908a141a0ae", "sha256:a3d90022f2202bbb14da991f26ca7a30b7e4c62bf0f8bf9825603b22d7e87494",
"sha256:aade5e66795c94e4a2b2624affeea8979648d1b0ae3fcee17e74e2c647fc4a8a", "sha256:a631fd36a9823638fe700d9225f9698fb59d049c942d322d4c09544dc2115356",
"sha256:b78905860336c1d292409e3df6ad39cc1f1c7f0964e66844bbc2ebfca434d073", "sha256:a6523a23a205be0fe664b6b8747a5c86d55da960d9586db039eec9f5c269c0e6",
"sha256:b92f521cdc4e4a3041cc343625b699f20b0b5f976793fb45681aac1efda565f8", "sha256:a756ecf9f4b9b3ed49a680a649af45a8767ad038de39e6c030919c2f443eb000",
"sha256:bfde84bbd6ae5f782206d454b67b7ee8f7f818c29b99fd02bf022fd33bab14cb", "sha256:b117287a5bdc81f1bac891187275ec7e829e961b8032c9e5ff38b70fd036c78f",
"sha256:c2b62d3df80e694c0e4a0ed47754c9480521e25642251b3ab1dff050a4e60409", "sha256:ba04f57d1715ca5ff74bb7f8a818bf929a204b3b3c2c2826d1e1cc3b1c13398c",
"sha256:c5e2be6c263b64f6f7656e23e18a4a9980cffc671442795682e8c4e4f815dd9f", "sha256:cd878195166723f30865e05d87cbaf9421614501a4bd48792c5ed28f90fd36ca",
"sha256:c99aa3c63104e0818ec566f8ff3942fb7c7a8f35f9912cb63fd8e12318b214b2", "sha256:cee815cc62d136e96cf76771b9d3eb58e0777ec18ea50de5cfcede8a7c429aa8",
"sha256:dae06620d3978da346375ebf88b9e2dd7d151335ba668c995aea9ed07af7add4", "sha256:d1722b7aa4b40cf93ac3c80d3edd48bf93b9208241d166a14ad8e7a20ee1d4f3",
"sha256:db5499d0710823fa4fb88206050d46544e8f0e0136a9a5f5570b026584c8fd74", "sha256:d7c1c06246b05529f9984435fc4fa5a545ea26606e7f450bdbe00c153f5aeaad",
"sha256:f36baafd82119c4a114b9518202f2a983819101dcc14b26e43fc12cbefdce00e", "sha256:e9c8066249c040efdda84793a2a669076f92a301ceabe69202446abb4c5c5ef9",
"sha256:f52b79c8796d81391ab295b04e520bda6feed54d54931708872e8f9ae9db0ea1", "sha256:f227d7e574d050ff3996049e086e1f18c7bd2d067ef24131e50a1d3fe5831fbc",
"sha256:ff8cff01582fa1a7e533cb97f628531c4014af4b5f38e33cdcfe5eec29b6d888" "sha256:fc9a12aad714af36cf3ad0275a96a733526571e52710319855628f476dcb144e"
], ],
"index": "pypi", "index": "pypi",
"version": "==5.3.0" "version": "==5.4.1"
}, },
"pycparser": { "pycparser": {
"hashes": [ "hashes": [
@ -393,10 +389,10 @@
}, },
"six": { "six": {
"hashes": [ "hashes": [
"sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9", "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c",
"sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb" "sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73"
], ],
"version": "==1.11.0" "version": "==1.12.0"
}, },
"sortedcontainers": { "sortedcontainers": {
"hashes": [ "hashes": [
@ -407,11 +403,11 @@
}, },
"typing-extensions": { "typing-extensions": {
"hashes": [ "hashes": [
"sha256:2a6c6e78e291a4b6cbd0bbfd30edc0baaa366de962129506ec8fe06bdec66457", "sha256:07b2c978670896022a43c4b915df8958bec4a6b84add7f2c87b2b728bda3ba64",
"sha256:51e7b7f3dcabf9ad22eed61490f3b8d23d9922af400fe6656cb08e66656b701f", "sha256:f3f0e67e1d42de47b5c67c32c9b26641642e9170fe7e292991793705cd5fef7c",
"sha256:55401f6ed58ade5638eb566615c150ba13624e2f0c1eedd080fc3c1b6cb76f1d" "sha256:fb2cd053238d33a8ec939190f30cfd736c00653a85a2919415cecf7dc3d9da71"
], ],
"version": "==3.6.6" "version": "==3.7.2"
}, },
"websockets": { "websockets": {
"hashes": [ "hashes": [
@ -442,70 +438,86 @@
}, },
"wsproto": { "wsproto": {
"hashes": [ "hashes": [
"sha256:1fcb726d448f1b9bcbea884e26621af5ddd01d2d502941a024f4c727828b6009", "sha256:c013342d7a9180486713c6c986872e4fe24e18a21ccbece314939d8b58312e0e",
"sha256:6a51cf18d9de612892b9c1d38a8c1bdadec0cfe15de61cd5c0f09174bf0c7e82" "sha256:fd6020d825022247053400306448e161d8740bdd52e328e5553cd9eee089f705"
], ],
"version": "==0.12.0" "version": "==0.13.0"
}, },
"yarl": { "yarl": {
"hashes": [ "hashes": [
"sha256:2556b779125621b311844a072e0ed367e8409a18fa12cbd68eb1258d187820f9", "sha256:024ecdc12bc02b321bc66b41327f930d1c2c543fa9a561b39861da9388ba7aa9",
"sha256:4aec0769f1799a9d4496827292c02a7b1f75c0bab56ab2b60dd94ebb57cbd5ee", "sha256:2f3010703295fbe1aec51023740871e64bb9664c789cba5a6bdf404e93f7568f",
"sha256:55369d95afaacf2fa6b49c84d18b51f1704a6560c432a0f9a1aeb23f7b971308", "sha256:3890ab952d508523ef4881457c4099056546593fa05e93da84c7250516e632eb",
"sha256:6c098b85442c8fe3303e708bbb775afd0f6b29f77612e8892627bcab4b939357", "sha256:3e2724eb9af5dc41648e5bb304fcf4891adc33258c6e14e2a7414ea32541e320",
"sha256:9182cd6f93412d32e009020a44d6d170d2093646464a88aeec2aef50592f8c78", "sha256:5badb97dd0abf26623a9982cd448ff12cb39b8e4c94032ccdedf22ce01a64842",
"sha256:c8cbc21bbfa1dd7d5386d48cc814fe3d35b80f60299cdde9279046f399c3b0d8", "sha256:73f447d11b530d860ca1e6b582f947688286ad16ca42256413083d13f260b7a0",
"sha256:db6f70a4b09cde813a4807843abaaa60f3b15fb4a2a06f9ae9c311472662daa1", "sha256:7ab825726f2940c16d92aaec7d204cfc34ac26c0040da727cf8ba87255a33829",
"sha256:f17495e6fe3d377e3faac68121caef6f974fcb9e046bc075bcff40d8e5cc69a4", "sha256:b25de84a8c20540531526dfbb0e2d2b648c13fd5dd126728c496d7c3fea33310",
"sha256:f85900b9cca0c67767bb61b2b9bd53208aaa7373dae633dbe25d179b4bf38aa7" "sha256:c6e341f5a6562af74ba55205dbd56d248daf1b5748ec48a0200ba227bb9e33f4",
"sha256:c9bb7c249c4432cd47e75af3864bc02d26c9594f49c82e2a28624417f0ae63b8",
"sha256:e060906c0c585565c718d1c3841747b61c5439af2211e185f6739a9412dfbde1"
], ],
"version": "==1.2.6" "version": "==1.3.0"
} }
}, },
"develop": { "develop": {
"atomicwrites": { "atomicwrites": {
"hashes": [ "hashes": [
"sha256:0312ad34fcad8fac3704d441f7b317e50af620823353ec657a53e981f92920c0", "sha256:03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4",
"sha256:ec9ae8adaae229e4f8446952d204a3e4b5fdd2d099f9be3aaf556120135fb3ee" "sha256:75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6"
], ],
"version": "==1.2.1" "version": "==1.3.0"
}, },
"attrs": { "attrs": {
"hashes": [ "hashes": [
"sha256:10cbf6e27dbce8c30807caf056c8eb50917e0eaafe86347671b57254006c3e69", "sha256:69c0dbf2ed392de1cb5ec704444b08a5ef81680a61cb899dc08127123af36a79",
"sha256:ca4be454458f9dec299268d472aaa5a11f67a4ff70093396e1ceae9c76cf4bbb" "sha256:f0b870f674851ecbfbbbd364d6b5cbdff9dcedbc7f3f5e18a6891057f21fe399"
], ],
"version": "==18.2.0" "version": "==19.1.0"
}, },
"more-itertools": { "more-itertools": {
"hashes": [ "hashes": [
"sha256:c187a73da93e7a8acc0001572aebc7e3c69daf7bf6881a2cea10650bd4420092", "sha256:0125e8f60e9e031347105eb1682cef932f5e97d7b9a1a28d9bf00c22a5daef40",
"sha256:c476b5d3a34e12d40130bc2f935028b5f636df8f372dc2c1c01dc19681b2039e", "sha256:590044e3942351a1bdb1de960b739ff4ce277960f2425ad4509446dbace8d9d1"
"sha256:fcbfeaea0be121980e15bc97b3817b5202ca73d0eae185b4550cbfce2a3ebb3d"
], ],
"version": "==4.3.0" "version": "==6.0.0"
},
"mypy": {
"hashes": [
"sha256:308c274eb8482fbf16006f549137ddc0d69e5a589465e37b99c4564414363ca7",
"sha256:e80fd6af34614a0e898a57f14296d0dacb584648f0339c2e000ddbf0f4cc2f8d"
],
"index": "pypi",
"version": "==0.670"
},
"mypy-extensions": {
"hashes": [
"sha256:37e0e956f41369209a3d5f34580150bcacfabaa57b33a15c0b25f4b5725e0812",
"sha256:b16cabe759f55e3409a7d231ebd2841378fb0c27a5d1994719e340e4f429ac3e"
],
"version": "==0.4.1"
}, },
"pluggy": { "pluggy": {
"hashes": [ "hashes": [
"sha256:447ba94990e8014ee25ec853339faf7b0fc8050cdc3289d4d71f7f410fb90095", "sha256:19ecf9ce9db2fce065a7a0586e07cfb4ac8614fe96edf628a264b1c70116cf8f",
"sha256:bde19360a8ec4dfd8a20dcb811780a30998101f078fc7ded6162f0076f50508f" "sha256:84d306a647cc805219916e62aab89caa97a33a1dd8c342e87a37f91073cd4746"
], ],
"version": "==0.8.0" "version": "==0.9.0"
}, },
"py": { "py": {
"hashes": [ "hashes": [
"sha256:bf92637198836372b520efcba9e020c330123be8ce527e535d185ed4b6f45694", "sha256:64f65755aee5b381cea27766a3a147c3f15b9b6b9ac88676de66ba2ae36793fa",
"sha256:e76826342cefe3c3d5f7e8ee4316b80d1dd8a300781612ddbc765c17ba25a6c6" "sha256:dc639b046a6e2cff5bbe40194ad65936d6ba360b52b3c3fe1d08a82dd50b5e53"
], ],
"version": "==1.7.0" "version": "==1.8.0"
}, },
"pyflakes": { "pyflakes": {
"hashes": [ "hashes": [
"sha256:9a7662ec724d0120012f6e29d6248ae3727d821bba522a0e6b356eff19126a49", "sha256:17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0",
"sha256:f661252913bc1dbe7fcfcbf0af0db3f42ab65aabd1a6ca68fe5d466bace94dae" "sha256:d976835886f8c5b31d47970ed689944a0262b5f3afa00a5a7b4dc81e5449f8a2"
], ],
"index": "pypi", "index": "pypi",
"version": "==2.0.0" "version": "==2.1.1"
}, },
"pytest": { "pytest": {
"hashes": [ "hashes": [
@ -525,10 +537,34 @@
}, },
"six": { "six": {
"hashes": [ "hashes": [
"sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9", "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c",
"sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb" "sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73"
], ],
"version": "==1.11.0" "version": "==1.12.0"
},
"typed-ast": {
"hashes": [
"sha256:035a54ede6ce1380599b2ce57844c6554666522e376bd111eb940fbc7c3dad23",
"sha256:037c35f2741ce3a9ac0d55abfcd119133cbd821fffa4461397718287092d9d15",
"sha256:049feae7e9f180b64efacbdc36b3af64a00393a47be22fa9cb6794e68d4e73d3",
"sha256:19228f7940beafc1ba21a6e8e070e0b0bfd1457902a3a81709762b8b9039b88d",
"sha256:2ea681e91e3550a30c2265d2916f40a5f5d89b59469a20f3bad7d07adee0f7a6",
"sha256:3a6b0a78af298d82323660df5497bcea0f0a4a25a0b003afd0ce5af049bd1f60",
"sha256:5385da8f3b801014504df0852bf83524599df890387a3c2b17b7caa3d78b1773",
"sha256:606d8afa07eef77280c2bf84335e24390055b478392e1975f96286d99d0cb424",
"sha256:69245b5b23bbf7fb242c9f8f08493e9ecd7711f063259aefffaeb90595d62287",
"sha256:6f6d839ab09830d59b7fa8fb6917023d8cb5498ee1f1dbd82d37db78eb76bc99",
"sha256:730888475f5ac0e37c1de4bd05eeb799fdb742697867f524dc8a4cd74bcecc23",
"sha256:9819b5162ffc121b9e334923c685b0d0826154e41dfe70b2ede2ce29034c71d8",
"sha256:9e60ef9426efab601dd9aa120e4ff560f4461cf8442e9c0a2b92548d52800699",
"sha256:af5fbdde0690c7da68e841d7fc2632345d570768ea7406a9434446d7b33b0ee1",
"sha256:b64efdbdf3bbb1377562c179f167f3bf301251411eb5ac77dec6b7d32bcda463",
"sha256:bac5f444c118aeb456fac1b0b5d14c6a71ea2a42069b09c176f75e9bd4c186f6",
"sha256:bda9068aafb73859491e13b99b682bd299c1b5fd50644d697533775828a28ee0",
"sha256:d659517ca116e6750101a1326107d3479028c5191f0ecee3c7203c50f5b915b0",
"sha256:eddd3fb1f3e0f82e5915a899285a39ee34ce18fd25d89582bc89fc9fb16cd2c6"
],
"version": "==1.3.1"
} }
} }
} }

3
docs/README.md Normal file
View File

@ -0,0 +1,3 @@
# Internal documentation
The Litecord Voice Server Protocol (LVSP) is documented here.

45
docs/admin_api.md Normal file
View File

@ -0,0 +1,45 @@
# Litecord Admin API
the base path is `/api/v6/admin`.
## GET `/voice/regions/<region>`
Return a list of voice server objects for the region.
Returns empty list if the region does not exist.
| field | type | description |
| --: | :-- | :-- |
| hostname | string | the hostname of the voice server |
| last\_health | float | the health of the voice server |
## PUT `/voice/regions`
Create a voice region.
Receives JSON body as input, returns a list of voice region objects as output.
| field | type | description |
| --: | :-- | :-- |
| id | string | id of the voice region, "brazil", "us-east", "eu-west", etc |
| name | string | name of the voice region |
| vip | Optional[bool] | if voice region is vip-only, default false |
| deprecated | Optional[bool] | if voice region is deprecated, default false |
| custom | Optional[bool] | if voice region is custom-only, default false |
## PUT `/voice/regions/<region>/server`
Create a voice server for a region.
Returns empty body with 204 status code on success.
| field | type | description |
| --: | :-- | :-- |
| hostname | string | the hostname of the voice server |
## PUT `/voice/regions/<region>/deprecate`
Mark a voice region as deprecated. Disables any voice actions on guilds that are
using the voice region.
Returns empty body with 204 status code on success.

228
docs/lvsp.md Normal file
View File

@ -0,0 +1,228 @@
# Litecord Voice Server Protocol (LVSP)
LVSP is a protocol for Litecord to communicate with an external component
dedicated for voice data. The voice server is responsible for the
Voice Websocket Discord and Voice UDP connections.
LVSP runs over a *long-lived* websocket with TLS. The encoding is JSON.
## OP code table
"client" is litecord. "server" is the voice server.
| opcode | name | sent by |
| --: | :-- | :-- |
| 0 | HELLO | server |
| 1 | IDENTIFY | client |
| 2 | RESUME | client |
| 3 | READY | server |
| 4 | HEARTBEAT | client |
| 5 | HEARTBEAT\_ACK | server |
| 6 | INFO | client / server |
## Message structure
Message data is defined by each opcode.
**Note:** the `snowflake` type follows the same rules as the Discord Gateway's
snowflake type: A string encoding a Discord Snowflake.
| field | type | description |
| --: | :-- | :-- |
| op | integer, opcode | operator code |
| d | map[string, any] | message data |
## High level overview
- connect, receive HELLO
- send IDENTIFY or RESUME
- if RESUME, process incoming messages as they were post-ready
- receive READY
- start HEARTBEAT'ing
- send INFO messages as needed
## Error codes
| code | meaning |
| --: | :-- |
| 4000 | general error. reconnect |
| 4001 | authentication failure |
| 4002 | decode error, given message failed to decode as json |
## HELLO message
Sent by the server when a connection is established.
| field | type | description |
| --: | :-- | :-- |
| heartbeat\_interval | integer | amount of milliseconds to heartbeat with |
| nonce | string | random 10-character string used in authentication |
## IDENTIFY message
Sent by the client to identify itself.
| field | type | description |
| --: | :-- | :-- |
| token | string | `HMAC(SHA256, key=[secret shared between server and client]), message=[nonce from HELLO]` |
## READY message
- The `health` field is described with more detail in the `HEARTBEAT_ACK`
message.
| field | type | description |
| --: | :-- | :-- |
| `health` | Health | server health |
## HEARTBEAT message
Sent by the client as a keepalive / health monitoring method.
The server MUST reply with a HEARTBEAT\_ACK message back in a reasonable
time period.
There are no other fields in this message.
## HEARTBEAT\_ACK message
Sent by the server in reply to a HEARTBEAT message coming from the client.
The `health` field is a measure of the servers's overall health. It is a
float going from 0 to 1, where 0 is the worst health possible, and 1 is the
best health possible.
Servers SHOULD use the same algorithm to determine health, it CAN be based off:
- Machine resource usage (RAM, CPU, etc), however they're too general and can
be unreliable.
- Total users connected.
- Total bandwidth used in some X amount of time.
Among others.
| field | type | description |
| --: | :-- | :-- |
| health | float | server health |
## INFO message
Sent by either client or a server to send information between eachother.
The INFO message is extensible in which many request / response scenarios
are laid on.
*This message type MUST be replayable.*
| field | type | description |
| --: | :-- | :-- |
| type | InfoType | info type |
| data | Any | info data, varies depending on InfoType |
### InfoType Enum
| value | name | description |
| --: | :-- | :-- |
| 0 | CHANNEL\_REQ | channel assignment request |
| 1 | CHANNEL\_ASSIGN | channel assignment reply |
| 2 | CHANNEL\_DESTROY | channel destroy |
| 3 | VST\_CREATE | voice state create request |
| 4 | VST\_DONE | voice state created |
| 5 | VST\_UPDATE | voice state update |
| 6 | VST\_LEAVE | voice state leave |
### CHANNEL\_REQ
Request a channel to be created inside the voice server.
The Server MUST reply back with a CHANNEL\_ASSIGN when resources are
allocated for the channel.
| field | type | description |
| --: | :-- | :-- |
| channel\_id | snowflake | channel id |
| guild\_id | Optional[snowflake] | guild id, not provided if dm / group dm |
### CHANNEL\_ASSIGN
Sent by the Server to signal the successful creation of a voice channel.
| field | type | description |
| --: | :-- | :-- |
| channel\_id | snowflake | channel id |
| guild\_id | Optional[snowflake] | guild id, not provided if dm / group dm |
| token | string | authentication token |
### CHANNEL\_DESTROY
Sent by the client to signal the destruction of a voice channel. Be it
a channel being deleted, or all members in it leaving.
Same data as CHANNEL\_ASSIGN, but without `token`.
### VST\_CREATE
Sent by the client to create a voice state.
| field | type | description |
| --: | :-- | :-- |
| user\_id | snowflake | user id |
| channel\_id | snowflake | channel id |
| guild\_id | Optional[snowflake] | guild id. not provided if dm / group dm |
### VST\_DONE
Sent by the server to indicate the success of a VST\_CREATE.
Has the same fields as VST\_CREATE, but with extras:
| field | type | description |
| --: | :-- | :-- |
| session\_id | string | session id for the voice state |
### VST\_DESTROY
Sent by the client when a user is leaving a channel OR moving between channels
in a guild. More on state transitions later on.
| field | type | description |
| --: | :-- | :-- |
| session\_id | string | session id for the voice state |
## Common logic scenarios
### User joins an unitialized voice channel
Since the channel is unitialized, both logic on initialization AND
user join is here.
- Client will send a CHANNEL\_REQ.
- Client MAY send a VST\_CREATE right after as well.
- The Server MUST process CHANNEL\_REQ first, so the Server can keep
a lock on channel operations while it is initialized.
- Reply with CHANNEL\_ASSIGN once initialization is done.
- Process VST\_CREATE
### Updating a voice channel
- Client sends CHANNEL\_UPDATE.
- Server DOES NOT reply.
### Destroying a voice channel
- Client sends CHANNEL\_DESTROY.
- Server MUST disconnect any users currently connected with its
voice websocket.
### User joining an (initialized) voice channel
- Client sends VST\_CREATE
- Server sends VST\_DONE
### User leaves a channel
- Client sends VST\_DESTROY with the old fields
### User moves a channel
- Client sends VST\_DESTROY with the old fields
- Client sends VST\_CREATE with the new fields
- Server sends VST\_DONE

31
litecord/admin_schemas.py Normal file
View File

@ -0,0 +1,31 @@
"""
Litecord
Copyright (C) 2018-2019 Luna Mendes
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, version 3 of the License.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
VOICE_SERVER = {
'hostname': {'type': 'string', 'maxlength': 255, 'required': True}
}
VOICE_REGION = {
'id': {'type': 'string', 'maxlength': 255, 'required': True},
'name': {'type': 'string', 'maxlength': 255, 'required': True},
'vip': {'type': 'boolean', 'default': False},
'deprecated': {'type': 'boolean', 'default': False},
'custom': {'type': 'boolean', 'default': False},
}

View File

@ -29,6 +29,7 @@ from quart import request, current_app as app
from litecord.errors import Forbidden, Unauthorized, BadRequest from litecord.errors import Forbidden, Unauthorized, BadRequest
from litecord.snowflake import get_snowflake from litecord.snowflake import get_snowflake
from litecord.enums import UserFlags
log = Logger(__name__) log = Logger(__name__)
@ -100,6 +101,23 @@ async def token_check():
return user_id return user_id
async def admin_check():
"""Check if the user is an admin."""
user_id = await token_check()
flags = await app.db.fetchval("""
SELECT flags
FROM users
WHERE id = $1
""", user_id)
flags = UserFlags.from_int(flags)
if not flags.is_staff:
raise Unauthorized('you are not staff')
return user_id
async def hash_data(data: str, loop=None) -> str: async def hash_data(data: str, loop=None) -> str:
"""Hash information with bcrypt.""" """Hash information with bcrypt."""
loop = loop or app.loop loop = loop or app.loop

View File

@ -17,6 +17,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
""" """
__all__ = ['websocket_handler'] from .voice import bp as voice
from .gateway import websocket_handler __all__ = ['voice']

View File

@ -0,0 +1,129 @@
"""
Litecord
Copyright (C) 2018-2019 Luna Mendes
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, version 3 of the License.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
import asyncpg
from quart import Blueprint, jsonify, current_app as app, request
from logbook import Logger
from litecord.auth import admin_check
from litecord.schemas import validate
from litecord.admin_schemas import VOICE_SERVER, VOICE_REGION
from litecord.errors import BadRequest
log = Logger(__name__)
bp = Blueprint('voice_admin', __name__)
@bp.route('/regions/<region>', methods=['GET'])
async def get_region_servers(region):
"""Return a list of all servers for a region."""
await admin_check()
servers = await app.voice.voice_server_list(region)
return jsonify(servers)
@bp.route('/regions', methods=['PUT'])
async def insert_new_region():
"""Create a voice region."""
await admin_check()
j = validate(await request.get_json(), VOICE_REGION)
j['id'] = j['id'].lower()
await app.db.execute("""
INSERT INTO voice_regions (id, name, vip, deprecated, custom)
VALUES ($1, $2, $3, $4, $5)
""", j['id'], j['name'], j['vip'], j['deprecated'], j['custom'])
regions = await app.storage.all_voice_regions()
region_count = len(regions)
# if region count is 1, this is the first region to be created,
# so we should update all guilds to that region
if region_count == 1:
res = await app.db.execute("""
UPDATE guilds
SET region = $1
""", j['id'])
log.info('updating guilds to first voice region: {}', res)
return jsonify(regions)
@bp.route('/regions/<region>/servers', methods=['PUT'])
async def put_region_server(region):
"""Insert a voice server to a region"""
await admin_check()
j = validate(await request.get_json(), VOICE_SERVER)
try:
await app.db.execute("""
INSERT INTO voice_servers (hostname, region_id)
VALUES ($1, $2)
""", j['hostname'], region)
except asyncpg.UniqueViolationError:
raise BadRequest('voice server already exists with given hostname')
return '', 204
@bp.route('/regions/<region>/deprecate', methods=['PUT'])
async def deprecate_region(region):
"""Deprecate a voice region."""
await admin_check()
# TODO: write this
await app.voice.disable_region(region)
await app.db.execute("""
UPDATE voice_regions
SET deprecated = true
WHERE id = $1
""", region)
return '', 204
async def guild_region_check(app_):
"""Check all guilds for voice region inconsistencies.
Since the voice migration caused all guilds.region columns
to become NULL, we need to remove such NULLs if we have more
than one region setup.
"""
regions = await app_.storage.all_voice_regions()
if not regions:
log.info('region check: no regions to move guilds to')
return
res = await app_.db.execute("""
UPDATE guilds
SET region = (
SELECT id
FROM voice_regions
OFFSET floor(random()*$1)
LIMIT 1
)
WHERE region = NULL
""", len(regions))
log.info('region check: updating guild.region=null: {!r}', res)

View File

@ -58,11 +58,11 @@ async def dm_pre_check(user_id: int, channel_id: int, peer_id: int):
user_settings = await app.user_storage.get_user_settings(user_id) user_settings = await app.user_storage.get_user_settings(user_id)
peer_settings = await app.user_storage.get_user_settings(peer_id) peer_settings = await app.user_storage.get_user_settings(peer_id)
restricted_user = [int(v) for v in user_settings['restricted_guilds']] restricted_user_ = [int(v) for v in user_settings['restricted_guilds']]
restricted_peer = [int(v) for v in peer_settings['restricted_guilds']] restricted_peer_ = [int(v) for v in peer_settings['restricted_guilds']]
restricted_user = set(restricted_user) restricted_user = set(restricted_user_)
restricted_peer = set(restricted_peer) restricted_peer = set(restricted_peer_)
mutual_guilds -= restricted_user mutual_guilds -= restricted_user
mutual_guilds -= restricted_peer mutual_guilds -= restricted_peer

View File

@ -217,7 +217,8 @@ async def _guild_text_mentions(payload: dict, guild_id: int,
# for the users that have a state # for the users that have a state
# in the channel. # in the channel.
if mentions_here: if mentions_here:
uids = [] uids = set()
await app.db.execute(""" await app.db.execute("""
UPDATE user_read_state UPDATE user_read_state
SET mention_count = mention_count + 1 SET mention_count = mention_count + 1
@ -229,7 +230,7 @@ async def _guild_text_mentions(payload: dict, guild_id: int,
# that might not have read permissions # that might not have read permissions
# to the channel. # to the channel.
if mentions_everyone: if mentions_everyone:
uids = [] uids = set()
member_ids = await app.storage.get_member_ids(guild_id) member_ids = await app.storage.get_member_ids(guild_id)
@ -407,9 +408,6 @@ async def _create_message(channel_id):
await _dm_pre_dispatch(channel_id, user_id) await _dm_pre_dispatch(channel_id, user_id)
await _dm_pre_dispatch(channel_id, guild_id) await _dm_pre_dispatch(channel_id, guild_id)
if payload['webhook_id'] == None:
payload.pop('webhook_id', None)
await app.dispatcher.dispatch('channel', channel_id, await app.dispatcher.dispatch('channel', channel_id,
'MESSAGE_CREATE', payload) 'MESSAGE_CREATE', payload)

View File

@ -18,6 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
""" """
import time import time
from typing import List, Optional
from quart import Blueprint, request, current_app as app, jsonify from quart import Blueprint, request, current_app as app, jsonify
from logbook import Logger from logbook import Logger
@ -262,8 +263,11 @@ async def _update_pos(channel_id, pos: int):
""", pos, channel_id) """, pos, channel_id)
async def _mass_chan_update(guild_id, channel_ids: int): async def _mass_chan_update(guild_id, channel_ids: List[Optional[int]]):
for channel_id in channel_ids: for channel_id in channel_ids:
if channel_id is None:
continue
chan = await app.storage.get_channel(channel_id) chan = await app.storage.get_channel(channel_id)
await app.dispatcher.dispatch( await app.dispatcher.dispatch(
'guild', guild_id, 'CHANNEL_UPDATE', chan) 'guild', guild_id, 'CHANNEL_UPDATE', chan)
@ -337,7 +341,7 @@ async def _update_channel_common(channel_id, guild_id: int, j: dict):
if 'position' in j: if 'position' in j:
channel_data = await app.storage.get_channel_data(guild_id) channel_data = await app.storage.get_channel_data(guild_id)
chans = [None * len(channel_data)] chans = [None] * len(channel_data)
for chandata in channel_data: for chandata in channel_data:
chans.insert(chandata['position'], int(chandata['id'])) chans.insert(chandata['position'], int(chandata['id']))
@ -393,7 +397,7 @@ async def _common_guild_chan(channel_id, j: dict):
""", j[field], channel_id) """, j[field], channel_id)
async def _update_text_channel(channel_id: int, j: dict): async def _update_text_channel(channel_id: int, j: dict, _user_id: int):
# first do the specific ones related to guild_text_channels # first do the specific ones related to guild_text_channels
for field in [field for field in j.keys() for field in [field for field in j.keys()
if field in ('topic', 'rate_limit_per_user')]: if field in ('topic', 'rate_limit_per_user')]:
@ -406,7 +410,7 @@ async def _update_text_channel(channel_id: int, j: dict):
await _common_guild_chan(channel_id, j) await _common_guild_chan(channel_id, j)
async def _update_voice_channel(channel_id: int, j: dict): async def _update_voice_channel(channel_id: int, j: dict, _user_id: int):
# first do the specific ones in guild_voice_channels # first do the specific ones in guild_voice_channels
for field in [field for field in j.keys() for field in [field for field in j.keys()
if field in ('bitrate', 'user_limit')]: if field in ('bitrate', 'user_limit')]:

View File

@ -29,7 +29,7 @@ bp = Blueprint('gateway', __name__)
def get_gw(): def get_gw():
"""Get the gateway's web""" """Get the gateway's web"""
proto = 'wss://' if app.config['IS_SSL'] else 'ws://' proto = 'wss://' if app.config['IS_SSL'] else 'ws://'
return f'{proto}{app.config["WEBSOCKET_URL"]}/ws' return f'{proto}{app.config["WEBSOCKET_URL"]}'
@bp.route('/gateway') @bp.route('/gateway')

View File

@ -68,7 +68,7 @@ async def get_members(guild_id):
async def _update_member_roles(guild_id: int, member_id: int, async def _update_member_roles(guild_id: int, member_id: int,
wanted_roles: list): wanted_roles: set):
"""Update the roles a member has.""" """Update the roles a member has."""
# first, fetch all current roles # first, fetch all current roles

View File

@ -17,7 +17,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
""" """
from typing import List, Dict from typing import List, Dict, Tuple
from quart import Blueprint, request, current_app as app, jsonify from quart import Blueprint, request, current_app as app, jsonify
from logbook import Logger from logbook import Logger
@ -184,10 +184,11 @@ async def _role_pairs_update(guild_id: int, pairs: list):
await _role_update_dispatch(role_1, guild_id) await _role_update_dispatch(role_1, guild_id)
await _role_update_dispatch(role_2, guild_id) await _role_update_dispatch(role_2, guild_id)
PairList = List[Tuple[Tuple[int, int], Tuple[int, int]]]
def gen_pairs(list_of_changes: List[Dict[str, int]], def gen_pairs(list_of_changes: List[Dict[str, int]],
current_state: Dict[int, int], current_state: Dict[int, int],
blacklist: List[int] = None) -> List[tuple]: blacklist: List[int] = None) -> PairList:
"""Generate a list of pairs that, when applied to the database, """Generate a list of pairs that, when applied to the database,
will generate the desired state given in list_of_changes. will generate the desired state given in list_of_changes.
@ -256,13 +257,16 @@ def gen_pairs(list_of_changes: List[Dict[str, int]],
# position we want to change to # position we want to change to
element_2 = current_state.get(new_pos_1) element_2 = current_state.get(new_pos_1)
if element_2 is None:
continue
# if there is, is that existing channel being # if there is, is that existing channel being
# swapped to another position? # swapped to another position?
new_pos_2 = preferred_state.get(element_2) new_pos_2 = preferred_state.get(element_2)
# if its being swapped to leave space, add it # if its being swapped to leave space, add it
# to the pairs list # to the pairs list
if new_pos_2: if new_pos_2 is not None:
pairs.append( pairs.append(
((element_1, new_pos_1), (element_2, new_pos_2)) ((element_1, new_pos_1), (element_2, new_pos_2))
) )

View File

@ -91,7 +91,7 @@ async def invite_precheck_gdm(user_id: int, channel_id: int):
async def _inv_check_age(inv: dict): async def _inv_check_age(inv: dict):
if inv['max_age'] is 0: if inv['max_age'] != 0:
return return
now = datetime.datetime.utcnow() now = datetime.datetime.utcnow()

View File

@ -148,8 +148,6 @@ async def get_payment_source(user_id: int, source_id: int, db=None) -> dict:
if not db: if not db:
db = app.db db = app.db
source = {}
source_type = await db.fetchval(""" source_type = await db.fetchval("""
SELECT source_type SELECT source_type
FROM user_payment_sources FROM user_payment_sources

View File

@ -17,13 +17,94 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
""" """
from quart import Blueprint, jsonify from typing import Optional
from collections import Counter
from random import choice
from quart import Blueprint, jsonify, current_app as app
from litecord.blueprints.auth import token_check
bp = Blueprint('voice', __name__) bp = Blueprint('voice', __name__)
def _majority_region_count(regions: list) -> str:
"""Return the first most common element in a given list."""
counter = Counter(regions)
common = counter.most_common(1)
region, _count = common[0]
return region
async def _choose_random_region() -> Optional[str]:
"""Give a random voice region."""
regions = await app.db.fetch("""
SELECT id
FROM voice_regions
""")
regions = [r['id'] for r in regions]
if not regions:
return None
return choice(regions)
async def _majority_region_any(user_id) -> Optional[str]:
"""Calculate the most likely region to make the user happy, but
this is based on the guilds the user is IN, instead of the guilds
the user owns."""
guilds = await app.user_storage.get_user_guilds(user_id)
if not guilds:
return await _choose_random_region()
res = []
for guild_id in guilds:
region = await app.db.fetchval("""
SELECT region
FROM guilds
WHERE id = $1
""", guild_id)
res.append(region)
most_common = _majority_region_count(res)
if most_common is None:
return await _choose_random_region()
return most_common
async def majority_region(user_id: int) -> Optional[str]:
"""Given a user ID, give the most likely region for the user to be
happy with."""
regions = await app.db.fetch("""
SELECT region
FROM guilds
WHERE owner_id = $1
""", user_id)
if not regions:
return await _majority_region_any(user_id)
regions = [r['region'] for r in regions]
return _majority_region_count(regions)
@bp.route('/regions', methods=['GET']) @bp.route('/regions', methods=['GET'])
async def voice_regions(): async def voice_regions():
return jsonify([ """Return voice regions."""
{'name': 'Brazil', 'deprecated': False, 'id': 'Brazil', 'optimal': True, 'vip': True} user_id = await token_check()
])
best_region = await majority_region(user_id)
regions = await app.storage.all_voice_regions()
for region in regions:
region['optimal'] = region['id'] == best_region
return jsonify(regions)

View File

@ -22,7 +22,7 @@ litecord.embed.sanitizer
sanitize embeds by giving common values sanitize embeds by giving common values
such as type: rich such as type: rich
""" """
from typing import Dict, Any from typing import Dict, Any, Optional, Union, List
from logbook import Logger from logbook import Logger
from quart import current_app as app from quart import current_app as app
@ -44,7 +44,7 @@ def sanitize_embed(embed: Embed) -> Embed:
}} }}
def path_exists(embed: Embed, components: str): def path_exists(embed: Embed, components_in: Union[List[str], str]):
"""Tell if a given path exists in an embed (or any dictionary). """Tell if a given path exists in an embed (or any dictionary).
The components string is formatted like this: The components string is formatted like this:
@ -54,10 +54,10 @@ def path_exists(embed: Embed, components: str):
""" """
# get the list of components given # get the list of components given
if isinstance(components, str): if isinstance(components_in, str):
components = components.split('.') components = components_in.split('.')
else: else:
components = list(components) components = list(components_in)
# if there are no components, we reached the end of recursion # if there are no components, we reached the end of recursion
# and can return true # and can return true
@ -96,7 +96,7 @@ def proxify(url, *, config=None) -> str:
) )
async def fetch_metadata(url, *, config=None, session=None) -> dict: async def fetch_metadata(url, *, config=None, session=None) -> Optional[Dict]:
"""Fetch metadata for a url.""" """Fetch metadata for a url."""
if session is None: if session is None:
@ -123,7 +123,7 @@ async def fetch_metadata(url, *, config=None, session=None) -> dict:
log.warning('failed to generate meta for {!r}: {} {!r}', log.warning('failed to generate meta for {!r}: {} {!r}',
url, resp.status, body) url, resp.status, body)
return return None
return await resp.json() return await resp.json()

View File

@ -83,6 +83,12 @@ GUILD_CHANS = (ChannelType.GUILD_TEXT,
ChannelType.GUILD_CATEGORY) ChannelType.GUILD_CATEGORY)
VOICE_CHANNELS = (
ChannelType.DM, ChannelType.GUILD_VOICE,
ChannelType.GUILD_CATEGORY
)
class ActivityType(EasyEnum): class ActivityType(EasyEnum):
PLAYING = 0 PLAYING = 0
STREAMING = 1 STREAMING = 1

View File

@ -133,8 +133,18 @@ class MissingPermissions(Forbidden):
class WebsocketClose(Exception): class WebsocketClose(Exception):
@property @property
def code(self): def code(self):
from_class = getattr(self, 'close_code', None)
if from_class:
return from_class
return self.args[0] return self.args[0]
@property @property
def reason(self): def reason(self):
from_class = getattr(self, 'close_code', None)
if from_class:
return self.args[0]
return self.args[1] return self.args[1]

View File

@ -30,7 +30,7 @@ from logbook import Logger
import earl import earl
from litecord.auth import raw_token_check from litecord.auth import raw_token_check
from litecord.enums import RelationshipType from litecord.enums import RelationshipType, ChannelType
from litecord.schemas import validate, GW_STATUS_UPDATE from litecord.schemas import validate, GW_STATUS_UPDATE
from litecord.utils import ( from litecord.utils import (
task_wrapper, LitecordJSONEncoder, yield_chunks task_wrapper, LitecordJSONEncoder, yield_chunks
@ -47,15 +47,20 @@ from litecord.gateway.errors import (
DecodeError, UnknownOPCode, InvalidShard, ShardingRequired DecodeError, UnknownOPCode, InvalidShard, ShardingRequired
) )
from litecord.storage import int_
log = Logger(__name__) log = Logger(__name__)
WebsocketProperties = collections.namedtuple( WebsocketProperties = collections.namedtuple(
'WebsocketProperties', 'v encoding compress zctx tasks' 'WebsocketProperties', 'v encoding compress zctx tasks'
) )
WebsocketObjects = collections.namedtuple( WebsocketObjects = collections.namedtuple(
'WebsocketObjects', ('db', 'state_manager', 'storage', 'WebsocketObjects', (
'db', 'state_manager', 'storage',
'loop', 'dispatcher', 'presence', 'ratelimiter', 'loop', 'dispatcher', 'presence', 'ratelimiter',
'user_storage') 'user_storage', 'voice'
)
) )
@ -113,7 +118,7 @@ class GatewayWebsocket:
self.ext = WebsocketObjects( self.ext = WebsocketObjects(
app.db, app.state_manager, app.storage, app.loop, app.db, app.state_manager, app.storage, app.loop,
app.dispatcher, app.presence, app.ratelimiter, app.dispatcher, app.presence, app.ratelimiter,
app.user_storage app.user_storage, app.voice
) )
self.storage = self.ext.storage self.storage = self.ext.storage
@ -230,7 +235,7 @@ class GatewayWebsocket:
's': None 's': None
}) })
def _check_ratelimit(self, key: str, ratelimit_key: str): def _check_ratelimit(self, key: str, ratelimit_key):
ratelimit = self.ext.ratelimiter.get_ratelimit(f'_ws.{key}') ratelimit = self.ext.ratelimiter.get_ratelimit(f'_ws.{key}')
bucket = ratelimit.get_bucket(ratelimit_key) bucket = ratelimit.get_bucket(ratelimit_key)
return bucket.update_rate_limit() return bucket.update_rate_limit()
@ -287,7 +292,7 @@ class GatewayWebsocket:
await self.send(payload) await self.send(payload)
async def _make_guild_list(self) -> List[int]: async def _make_guild_list(self) -> List[Dict[str, Any]]:
user_id = self.state.user_id user_id = self.state.user_id
guild_ids = await self._guild_ids() guild_ids = await self._guild_ids()
@ -598,16 +603,85 @@ class GatewayWebsocket:
# setting new presence to state # setting new presence to state
await self.update_status(presence) await self.update_status(presence)
def voice_key(self, channel_id: int, guild_id: int):
"""Voice state key."""
return (self.state.user_id, self.state.session_id)
async def _vsu_get_prop(self, state, data):
"""Get voice state properties from data, fallbacking to
user settings."""
try:
# TODO: fetch from settings if not provided
self_deaf = bool(data['self_deaf'])
self_mute = bool(data['self_mute'])
except (KeyError, ValueError):
pass
return {
'deaf': state.deaf,
'mute': state.mute,
'self_deaf': self_deaf,
'self_mute': self_mute,
}
async def handle_4(self, payload: Dict[str, Any]): async def handle_4(self, payload: Dict[str, Any]):
"""Handle OP 4 Voice Status Update.""" """Handle OP 4 Voice Status Update."""
data = payload['d'] data = payload['d']
# for now, ignore
log.debug('got VSU cid={} gid={} deaf={} mute={} video={}', if not self.state:
data.get('channel_id'), return
data.get('guild_id'),
data.get('self_deaf'), channel_id = int_(data.get('channel_id'))
data.get('self_mute'), guild_id = int_(data.get('guild_id'))
data.get('self_video'))
# if its null and null, disconnect the user from any voice
# TODO: maybe just leave from DMs? idk...
if channel_id is None and guild_id is None:
return await self.ext.voice.leave_all(self.state.user_id)
# if guild is not none but channel is, we are leaving
# a guild's channel
if channel_id is None:
return await self.ext.voice.leave(guild_id, self.state.user_id)
# fetch an existing state given user and guild OR user and channel
chan_type = ChannelType(
await self.storage.get_chan_type(channel_id)
)
state_id2 = channel_id
if chan_type == ChannelType.GUILD_VOICE:
state_id2 = guild_id
# a voice state key is a Tuple[int, int]
# - [0] is the user id
# - [1] is the channel id or guild id
# the old approach was a (user_id, session_id), but
# that does not work.
# this works since users can be connected to many channels
# using a single gateway websocket connection. HOWEVER,
# they CAN NOT enter two channels in a single guild.
# this state id format takes care of that.
voice_key = (self.state.user_id, state_id2)
voice_state = await self.ext.voice.get_state(voice_key)
if voice_state is None:
return await self.ext.voice.create_state(voice_key, data)
same_guild = guild_id == voice_state.guild_id
same_channel = channel_id == voice_state.channel_id
prop = await self._vsu_get_prop(voice_state, data)
if same_guild and same_channel:
return await self.ext.voice.update_state(voice_state, prop)
if same_guild and not same_channel:
return await self.ext.voice.move_state(voice_state, channel_id)
async def _handle_5(self, payload: Dict[str, Any]): async def _handle_5(self, payload: Dict[str, Any]):
"""Handle OP 5 Voice Server Ping. """Handle OP 5 Voice Server Ping.
@ -698,7 +772,7 @@ class GatewayWebsocket:
await self._resume(range(seq, state.seq)) await self._resume(range(seq, state.seq))
async def _req_guild_members(self, guild_id: str, user_ids: List[int], async def _req_guild_members(self, guild_id, user_ids: List[int],
query: str, limit: int): query: str, limit: int):
try: try:
guild_id = int(guild_id) guild_id = int(guild_id)

View File

@ -22,6 +22,7 @@ import mimetypes
import asyncio import asyncio
import base64 import base64
import tempfile import tempfile
from typing import Optional
from dataclasses import dataclass from dataclasses import dataclass
from hashlib import sha256 from hashlib import sha256
@ -67,22 +68,33 @@ def get_mime(ext: str):
@dataclass @dataclass
class Icon: class Icon:
"""Main icon class""" """Main icon class"""
key: str key: Optional[str]
icon_hash: str icon_hash: Optional[str]
mime: str mime: Optional[str]
@property @property
def as_path(self) -> str: def as_path(self) -> Optional[str]:
"""Return a filesystem path for the given icon.""" """Return a filesystem path for the given icon."""
if self.mime is None:
return None
ext = get_ext(self.mime) ext = get_ext(self.mime)
return str(IMAGE_FOLDER / f'{self.key}_{self.icon_hash}.{ext}') return str(IMAGE_FOLDER / f'{self.key}_{self.icon_hash}.{ext}')
@property @property
def as_pathlib(self) -> str: def as_pathlib(self) -> Optional[Path]:
"""Get a Path instance of this icon."""
if self.as_path is None:
return None
return Path(self.as_path) return Path(self.as_path)
@property @property
def extension(self) -> str: def extension(self) -> Optional[str]:
"""Get the extension of this icon."""
if self.mime is None:
return None
return get_ext(self.mime) return get_ext(self.mime)
@ -91,7 +103,7 @@ class ImageError(Exception):
pass pass
def to_raw(data_type: str, data: str) -> bytes: def to_raw(data_type: str, data: str) -> Optional[bytes]:
"""Given a data type in the data URI and data, """Given a data type in the data URI and data,
give the raw bytes being encoded.""" give the raw bytes being encoded."""
if data_type == 'base64': if data_type == 'base64':
@ -176,7 +188,7 @@ def _gen_update_sql(scope: str) -> str:
""" """
def _invalid(kwargs: dict): def _invalid(kwargs: dict) -> Optional[Icon]:
"""Send an invalid value.""" """Send an invalid value."""
if not kwargs.get('always_icon', False): if not kwargs.get('always_icon', False):
return None return None
@ -272,7 +284,8 @@ class IconManager:
return Icon(icon.key, icon.icon_hash, target_mime) return Icon(icon.key, icon.icon_hash, target_mime)
async def generic_get(self, scope, key, icon_hash, **kwargs) -> Icon: async def generic_get(self, scope, key, icon_hash,
**kwargs) -> Optional[Icon]:
"""Get any icon.""" """Get any icon."""
log.debug('GET {} {} {}', scope, key, icon_hash) log.debug('GET {} {} {}', scope, key, icon_hash)
@ -300,10 +313,17 @@ class IconManager:
icon = Icon(icon_row['key'], icon_row['hash'], icon_row['mime']) icon = Icon(icon_row['key'], icon_row['hash'], icon_row['mime'])
# ensure we aren't messing with NULLs everywhere.
if icon.as_pathlib is None:
return None
if not icon.as_pathlib.exists(): if not icon.as_pathlib.exists():
await self.delete(icon) await self.delete(icon)
return None return None
if icon.extension is None:
return None
if 'ext' in kwargs and kwargs['ext'] != icon.extension: if 'ext' in kwargs and kwargs['ext'] != icon.extension:
return await self._convert_ext(icon, kwargs['ext']) return await self._convert_ext(icon, kwargs['ext'])

View File

@ -18,6 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
""" """
import ctypes import ctypes
from typing import Optional
from quart import current_app as app from quart import current_app as app
@ -198,7 +199,8 @@ async def role_permissions(guild_id: int, role_id: int,
async def compute_overwrites(base_perms: Permissions, async def compute_overwrites(base_perms: Permissions,
user_id, channel_id: int, user_id, channel_id: int,
guild_id: int = None, storage=None): guild_id: Optional[int] = None,
storage=None):
"""Compute the permissions in the context of a channel.""" """Compute the permissions in the context of a channel."""
if not storage: if not storage:
storage = app.storage storage = app.storage
@ -211,8 +213,12 @@ async def compute_overwrites(base_perms: Permissions,
# list of overwrites # list of overwrites
overwrites = await storage.chan_overwrites(channel_id) overwrites = await storage.chan_overwrites(channel_id)
# if the channel isn't a guild, we should just return
# ALL_PERMISSIONS. the old approach was calling guild_from_channel
# again, but it is already passed by get_permissions(), so its
# redundant.
if not guild_id: if not guild_id:
guild_id = await storage.guild_from_channel(channel_id) return ALL_PERMISSIONS
# make it a map for better usage # make it a map for better usage
overwrites = {int(o['id']): o for o in overwrites} overwrites = {int(o['id']): o for o in overwrites}

View File

@ -127,7 +127,7 @@ class PresenceManager:
# shards that are in lazy guilds with 'everyone' # shards that are in lazy guilds with 'everyone'
# enabled # enabled
in_lazy = [] in_lazy: List[str] = []
for member_list in lists: for member_list in lists:
session_ids = await member_list.pres_update( session_ids = await member_list.pres_update(

View File

@ -17,7 +17,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
""" """
from typing import Any from typing import Any, List
from logbook import Logger from logbook import Logger
@ -54,13 +54,13 @@ class ChannelDispatcher(DispatcherWithState):
VAL_TYPE = int VAL_TYPE = int
async def dispatch(self, channel_id, async def dispatch(self, channel_id,
event: str, data: Any): event: str, data: Any) -> List[str]:
"""Dispatch an event to a channel.""" """Dispatch an event to a channel."""
# get everyone who is subscribed # get everyone who is subscribed
# and store the number of states we dispatched the event to # and store the number of states we dispatched the event to
user_ids = self.state[channel_id] user_ids = self.state[channel_id]
dispatched = 0 dispatched = 0
sessions = [] sessions: List[str] = []
# making a copy of user_ids since # making a copy of user_ids since
# we'll modify it later on. # we'll modify it later on.
@ -84,7 +84,7 @@ class ChannelDispatcher(DispatcherWithState):
await self.unsub(channel_id, user_id) await self.unsub(channel_id, user_id)
continue continue
cur_sess = 0 cur_sess = []
if event in ('CHANNEL_CREATE', 'CHANNEL_UPDATE') \ if event in ('CHANNEL_CREATE', 'CHANNEL_UPDATE') \
and data.get('type') == ChannelType.GROUP_DM.value: and data.get('type') == ChannelType.GROUP_DM.value:

View File

@ -17,9 +17,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
""" """
""" from typing import List
litecord.pubsub.dispatcher: main dispatcher class
"""
from collections import defaultdict from collections import defaultdict
from logbook import Logger from logbook import Logger
@ -82,7 +80,8 @@ class Dispatcher:
""" """
raise NotImplementedError raise NotImplementedError
async def _dispatch_states(self, states: list, event: str, data) -> int: async def _dispatch_states(self, states: list, event: str,
data) -> List[str]:
"""Dispatch an event to a list of states.""" """Dispatch an event to a list of states."""
res = [] res = []

View File

@ -28,7 +28,9 @@ lazy guilds:
import asyncio import asyncio
from collections import defaultdict from collections import defaultdict
from typing import Any, List, Dict, Union from typing import (
Any, List, Dict, Union, Optional, Iterable, Iterator, Tuple, Set
)
from dataclasses import dataclass, asdict, field from dataclasses import dataclass, asdict, field
from logbook import Logger from logbook import Logger
@ -39,7 +41,7 @@ from litecord.permissions import (
) )
from litecord.utils import index_by_func from litecord.utils import index_by_func
from litecord.utils import mmh3 from litecord.utils import mmh3
from litecord.gateway.state import GatewayState
log = Logger(__name__) log = Logger(__name__)
@ -113,7 +115,7 @@ class MemberList:
yield group, self.data[group.gid] yield group, self.data[group.gid]
@property @property
def iter_non_empty(self) -> tuple: def iter_non_empty(self) -> Iterator[Tuple[GroupInfo, List[int]]]:
"""Only iterate through non-empty groups. """Only iterate through non-empty groups.
Note that while the offline group can be empty, it is always Note that while the offline group can be empty, it is always
@ -265,7 +267,7 @@ class GuildMemberList:
#: store the states that are subscribed to the list. #: store the states that are subscribed to the list.
# type is {session_id: set[list]} # type is {session_id: set[list]}
self.state = defaultdict(set) self.state: Dict[str, Set[List[int, int]]] = defaultdict(set)
self._list_lock = asyncio.Lock() self._list_lock = asyncio.Lock()
@ -359,7 +361,7 @@ class GuildMemberList:
# then the final perms for that role if # then the final perms for that role if
# any overwrite exists in the channel # any overwrite exists in the channel
final_perms = overwrite_find_mix( final_perms = overwrite_find_mix(
role_perms, self.list.overwrites, group.gid) role_perms, self.list.overwrites, int(group.gid))
# update the group's permissions # update the group's permissions
# with the mixed ones # with the mixed ones
@ -423,7 +425,7 @@ class GuildMemberList:
async def _get_group_for_member(self, member_id: int, async def _get_group_for_member(self, member_id: int,
roles: List[Union[str, int]], roles: List[Union[str, int]],
status: str) -> GroupID: status: str) -> Optional[GroupID]:
"""Return a fitting group ID for the member.""" """Return a fitting group ID for the member."""
member_roles = list(map(int, roles)) member_roles = list(map(int, roles))
@ -463,15 +465,15 @@ class GuildMemberList:
self.list.members[member_id] = member self.list.members[member_id] = member
self.list.data[group_id].append(member_id) self.list.data[group_id].append(member_id)
def _display_name(self, member_id: int) -> str: def _display_name(self, member_id: int) -> Optional[str]:
"""Get the display name for a given member. """Get the display name for a given member.
This is more efficient than the old function (not method) of same This is more efficient than the old function (not method) of same
name, as we dont need to pass nickname information to it. name, as we dont need to pass nickname information to it.
""" """
member = self.list.members.get(member_id) try:
member = self.list.members[member_id]
if not member_id: except KeyError:
return None return None
username = member['user']['username'] username = member['user']['username']
@ -578,7 +580,7 @@ class GuildMemberList:
if not self.state: if not self.state:
self._set_empty_list() self._set_empty_list()
def _get_state(self, session_id: str): def _get_state(self, session_id: str) -> Optional[GatewayState]:
"""Get the state for a session id. """Get the state for a session id.
Wrapper for :meth:`StateManager.fetch_raw` Wrapper for :meth:`StateManager.fetch_raw`
@ -589,7 +591,7 @@ class GuildMemberList:
except KeyError: except KeyError:
return None return None
async def _dispatch_sess(self, session_ids: List[str], async def _dispatch_sess(self, session_ids: Iterable[str],
operations: List[Operation]): operations: List[Operation]):
"""Dispatch a GUILD_MEMBER_LIST_UPDATE to the """Dispatch a GUILD_MEMBER_LIST_UPDATE to the
given session ids.""" given session ids."""
@ -613,11 +615,12 @@ class GuildMemberList:
} }
states = map(self._get_state, session_ids) states = map(self._get_state, session_ids)
states = filter(lambda state: state is not None, states)
dispatched = [] dispatched = []
for state in states: for state in states:
if state is None:
continue
await state.ws.dispatch( await state.ws.dispatch(
'GUILD_MEMBER_LIST_UPDATE', payload) 'GUILD_MEMBER_LIST_UPDATE', payload)
@ -625,7 +628,8 @@ class GuildMemberList:
return dispatched return dispatched
async def _resync(self, session_ids: int, item_index: int) -> List[str]: async def _resync(self, session_ids: List[str],
item_index: int) -> List[str]:
"""Send a SYNC event to all states that are subscribed to an item. """Send a SYNC event to all states that are subscribed to an item.
Returns Returns
@ -660,7 +664,7 @@ class GuildMemberList:
return result return result
async def _resync_by_item(self, item_index: int): async def _resync_by_item(self, item_index: Optional[int]):
"""Resync but only giving the item index.""" """Resync but only giving the item index."""
if item_index is None: if item_index is None:
return [] return []
@ -729,7 +733,7 @@ class GuildMemberList:
# send SYNCs to the state that requested # send SYNCs to the state that requested
await self._dispatch_sess([session_id], ops) await self._dispatch_sess([session_id], ops)
def _get_item_index(self, user_id: Union[str, int]) -> int: def _get_item_index(self, user_id: Union[str, int]) -> Optional[int]:
"""Get the item index a user is on.""" """Get the item index a user is on."""
# NOTE: this is inefficient # NOTE: this is inefficient
user_id = int(user_id) user_id = int(user_id)
@ -749,7 +753,7 @@ class GuildMemberList:
return None return None
def _get_group_item_index(self, group_id: GroupID) -> int: def _get_group_item_index(self, group_id: GroupID) -> Optional[int]:
"""Get the item index a group is on.""" """Get the item index a group is on."""
index = 0 index = 0
@ -773,7 +777,7 @@ class GuildMemberList:
return False return False
def _get_subs(self, item_index: int) -> filter: def _get_subs(self, item_index: int) -> Iterable[str]:
"""Get the list of subscribed states to a given item.""" """Get the list of subscribed states to a given item."""
return filter( return filter(
lambda sess_id: self._is_subbed(item_index, sess_id), lambda sess_id: self._is_subbed(item_index, sess_id),
@ -1141,7 +1145,7 @@ class GuildMemberList:
# when bots come along. # when bots come along.
self.list.data[new_group.gid] = [] self.list.data[new_group.gid] = []
def _get_role_as_group_idx(self, role_id: int) -> int: def _get_role_as_group_idx(self, role_id: int) -> Optional[int]:
"""Get a group index representing the given role id. """Get a group index representing the given role id.
Returns Returns
@ -1338,8 +1342,11 @@ class GuildMemberList:
log.debug('there are {} session ids to resync (for item {})', log.debug('there are {} session ids to resync (for item {})',
len(sess_ids_resync), role_item_index) len(sess_ids_resync), role_item_index)
if role_item_index is not None:
return await self._resync(sess_ids_resync, role_item_index) return await self._resync(sess_ids_resync, role_item_index)
return []
async def chan_update(self): async def chan_update(self):
"""Called then a channel's data has been updated.""" """Called then a channel's data has been updated."""
await self._fetch_overwrites() await self._fetch_overwrites()
@ -1443,10 +1450,10 @@ class LazyGuildDispatcher(Dispatcher):
# remove it from guild map as well # remove it from guild map as well
guild_id = gml.guild_id guild_id = gml.guild_id
self.guild_map[guild_id].pop(channel_id) self.guild_map[guild_id].remove(channel_id)
gml.close() gml.close()
except KeyError: except (KeyError, ValueError):
pass pass
async def chan_update(self, channel_id: int): async def chan_update(self, channel_id: int):

View File

@ -147,7 +147,7 @@ class LitecordValidator(Validator):
def validate(reqjson: Union[Dict, List], schema: Dict, def validate(reqjson: Union[Dict, List], schema: Dict,
raise_err: bool = True) -> Union[Dict, List]: raise_err: bool = True) -> Dict:
"""Validate a given document (user-input) and give """Validate a given document (user-input) and give
the correct document as a result. the correct document as a result.
""" """

View File

@ -17,7 +17,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
""" """
from typing import List, Dict, Any from typing import List, Dict, Any, Optional
from logbook import Logger from logbook import Logger
@ -52,11 +52,17 @@ def str_(val):
return maybe(str, val) return maybe(str, val)
def _filter_recipients(recipients: List[Dict[str, Any]], user_id: int): def int_(val):
return maybe(int, val)
def bool_(val):
return maybe(int, val)
def _filter_recipients(recipients: List[Dict[str, Any]], user_id: str):
"""Filter recipients in a list of recipients, removing """Filter recipients in a list of recipients, removing
the one that is reundant (ourselves).""" the one that is reundant (ourselves)."""
user_id = str(user_id)
return list(filter( return list(filter(
lambda recipient: recipient['id'] != user_id, lambda recipient: recipient['id'] != user_id,
recipients)) recipients))
@ -69,7 +75,7 @@ class Storage:
self.db = app.db self.db = app.db
self.presence = None self.presence = None
async def fetchrow_with_json(self, query: str, *args): async def fetchrow_with_json(self, query: str, *args) -> Any:
"""Fetch a single row with JSON/JSONB support.""" """Fetch a single row with JSON/JSONB support."""
# the pool by itself doesn't have # the pool by itself doesn't have
# set_type_codec, so we must set it manually # set_type_codec, so we must set it manually
@ -78,19 +84,19 @@ class Storage:
await pg_set_json(con) await pg_set_json(con)
return await con.fetchrow(query, *args) return await con.fetchrow(query, *args)
async def fetch_with_json(self, query: str, *args): async def fetch_with_json(self, query: str, *args) -> List[Any]:
"""Fetch many rows with JSON/JSONB support.""" """Fetch many rows with JSON/JSONB support."""
async with self.db.acquire() as con: async with self.db.acquire() as con:
await pg_set_json(con) await pg_set_json(con)
return await con.fetch(query, *args) return await con.fetch(query, *args)
async def execute_with_json(self, query: str, *args): async def execute_with_json(self, query: str, *args) -> str:
"""Execute a SQL statement with JSON/JSONB support.""" """Execute a SQL statement with JSON/JSONB support."""
async with self.db.acquire() as con: async with self.db.acquire() as con:
await pg_set_json(con) await pg_set_json(con)
return await con.execute(query, *args) return await con.execute(query, *args)
async def get_user(self, user_id, secure=False) -> Dict[str, Any]: async def get_user(self, user_id, secure=False) -> Optional[Dict[str, Any]]:
"""Get a single user payload.""" """Get a single user payload."""
user_id = int(user_id) user_id = int(user_id)
@ -107,7 +113,7 @@ class Storage:
""", user_id) """, user_id)
if not user_row: if not user_row:
return return None
duser = dict(user_row) duser = dict(user_row)
@ -133,14 +139,14 @@ class Storage:
"""Search a user""" """Search a user"""
if len(discriminator) < 4: if len(discriminator) < 4:
# how do we do this in f-strings again..? # how do we do this in f-strings again..?
discriminator = '%04d' % discriminator discriminator = '%04d' % int(discriminator)
return await self.db.fetchval(""" return await self.db.fetchval("""
SELECT id FROM users SELECT id FROM users
WHERE username = $1 AND discriminator = $2 WHERE username = $1 AND discriminator = $2
""", username, discriminator) """, username, discriminator)
async def get_guild(self, guild_id: int, user_id=None) -> Dict: async def get_guild(self, guild_id: int, user_id=None) -> Optional[Dict]:
"""Get gulid payload.""" """Get gulid payload."""
row = await self.db.fetchrow(""" row = await self.db.fetchrow("""
SELECT id::text, owner_id::text, name, icon, splash, SELECT id::text, owner_id::text, name, icon, splash,
@ -155,7 +161,7 @@ class Storage:
""", guild_id) """, guild_id)
if not row: if not row:
return return None
drow = dict(row) drow = dict(row)
@ -165,14 +171,36 @@ class Storage:
return drow return drow
async def _member_basic(self, guild_id: int, member_id: int): async def _member_basic(self, guild_id: int, member_id: int):
return await self.db.fetchrow(""" row = await self.db.fetchrow("""
SELECT user_id, nickname, joined_at, deafened, muted SELECT user_id, nickname, joined_at,
deafened AS deaf, muted AS mute
FROM members FROM members
WHERE guild_id = $1 and user_id = $2 WHERE guild_id = $1 and user_id = $2
""", guild_id, member_id) """, guild_id, member_id)
if row is None:
return None
row = dict(row)
row['joined_at'] = timestamp_(row['joined_at'])
return row
async def _member_basic_with_roles(self, guild_id: int,
member_id: int):
basic = await self._member_basic(guild_id, member_id)
if basic is None:
return None
basic = dict(basic)
roles = await self.get_member_role_ids(guild_id, member_id)
return {**basic, **{
'roles': roles
}}
async def get_member_role_ids(self, guild_id: int, async def get_member_role_ids(self, guild_id: int,
member_id: int) -> List[int]: member_id: int) -> List[str]:
"""Get a list of role IDs that are on a member.""" """Get a list of role IDs that are on a member."""
roles = await self.db.fetch(""" roles = await self.db.fetch("""
SELECT role_id::text SELECT role_id::text
@ -197,6 +225,7 @@ class Storage:
async def _member_dict(self, row, guild_id, member_id) -> Dict[str, Any]: async def _member_dict(self, row, guild_id, member_id) -> Dict[str, Any]:
roles = await self.get_member_role_ids(guild_id, member_id) roles = await self.get_member_role_ids(guild_id, member_id)
return { return {
'user': await self.get_user(member_id), 'user': await self.get_user(member_id),
'nick': row['nickname'], 'nick': row['nickname'],
@ -205,18 +234,18 @@ class Storage:
# the user since it is known that everyone has # the user since it is known that everyone has
# that role. # that role.
'roles': roles, 'roles': roles,
'joined_at': timestamp_(row['joined_at']), 'joined_at': row['joined_at'],
'deaf': row['deafened'], 'deaf': row['deaf'],
'mute': row['muted'], 'mute': row['mute'],
} }
async def get_member_data_one(self, guild_id: int, async def get_member_data_one(self, guild_id: int,
member_id: int) -> Dict[str, Any]: member_id: int) -> Optional[Dict[str, Any]]:
"""Get data about one member in a guild.""" """Get data about one member in a guild."""
basic = await self._member_basic(guild_id, member_id) basic = await self._member_basic(guild_id, member_id)
if not basic: if not basic:
return return None
return await self._member_dict(basic, guild_id, member_id) return await self._member_dict(basic, guild_id, member_id)
@ -238,7 +267,8 @@ class Storage:
async def get_member_data(self, guild_id: int) -> List[Dict[str, Any]]: async def get_member_data(self, guild_id: int) -> List[Dict[str, Any]]:
"""Get member information on a guild.""" """Get member information on a guild."""
members_basic = await self.db.fetch(""" members_basic = await self.db.fetch("""
SELECT user_id, nickname, joined_at, deafened, muted SELECT user_id, nickname, joined_at,
deafened AS deaf, muted AS mute
FROM members FROM members
WHERE guild_id = $1 WHERE guild_id = $1
""", guild_id) """, guild_id)
@ -314,6 +344,7 @@ class Storage:
return {**row, **dict(vrow)} return {**row, **dict(vrow)}
log.warning('unknown channel type: {}', chan_type) log.warning('unknown channel type: {}', chan_type)
return row
async def get_chan_type(self, channel_id: int) -> int: async def get_chan_type(self, channel_id: int) -> int:
"""Get the channel type integer, given channel ID.""" """Get the channel type integer, given channel ID."""
@ -368,7 +399,7 @@ class Storage:
return [r['member_id'] for r in user_ids] return [r['member_id'] for r in user_ids]
async def _gdm_recipients(self, channel_id: int, async def _gdm_recipients(self, channel_id: int,
reference_id: int = None) -> List[int]: reference_id: int = None) -> List[Dict]:
"""Get the list of users that are recipients of the """Get the list of users that are recipients of the
given Group DM.""" given Group DM."""
recipients = await self.gdm_recipient_ids(channel_id) recipients = await self.gdm_recipient_ids(channel_id)
@ -378,13 +409,17 @@ class Storage:
if user_id == reference_id: if user_id == reference_id:
continue continue
res.append( user = await self.get_user(user_id)
await self.get_user(user_id)
) if user is None:
continue
res.append(user)
return res return res
async def get_channel(self, channel_id: int, **kwargs) -> Dict[str, Any]: async def get_channel(self, channel_id: int,
**kwargs) -> Optional[Dict[str, Any]]:
"""Fetch a single channel's information.""" """Fetch a single channel's information."""
chan_type = await self.get_chan_type(channel_id) chan_type = await self.get_chan_type(channel_id)
ctype = ChannelType(chan_type) ctype = ChannelType(chan_type)
@ -493,7 +528,7 @@ class Storage:
return channels return channels
async def get_role(self, role_id: int, async def get_role(self, role_id: int,
guild_id: int = None) -> Dict[str, Any]: guild_id: int = None) -> Optional[Dict[str, Any]]:
"""get a single role's information.""" """get a single role's information."""
guild_field = 'AND guild_id = $2' if guild_id else '' guild_field = 'AND guild_id = $2' if guild_id else ''
@ -511,7 +546,7 @@ class Storage:
""", *args) """, *args)
if not row: if not row:
return return None
return dict(row) return dict(row)
@ -527,6 +562,27 @@ class Storage:
return list(map(dict, roledata)) return list(map(dict, roledata))
async def guild_voice_states(self, guild_id: int,
user_id=None) -> List[Dict[str, Any]]:
"""Get a list of voice states for the given guild."""
channel_ids = await self.get_channel_ids(guild_id)
res = []
for channel_id in channel_ids:
states = await self.app.voice.fetch_states(channel_id)
jsonified = [s.as_json_for(user_id) for s in states.values()]
# discord does NOT insert guild_id to voice states on the
# guild voice state list.
for state in jsonified:
state.pop('guild_id')
res.extend(jsonified)
return res
async def get_guild_extra(self, guild_id: int, async def get_guild_extra(self, guild_id: int,
user_id=None, large=None) -> Dict: user_id=None, large=None) -> Dict:
"""Get extra information about a guild.""" """Get extra information about a guild."""
@ -567,18 +623,20 @@ class Storage:
), ),
'emojis': await self.get_guild_emojis(guild_id), 'emojis': await self.get_guild_emojis(guild_id),
'voice_states': await self.guild_voice_states(guild_id),
# TODO: voice state management
'voice_states': [],
}} }}
async def get_guild_full(self, guild_id: int, async def get_guild_full(self, guild_id: int, user_id: int,
user_id: int, large_count: int = 250) -> Dict: large_count: int = 250) -> Optional[Dict]:
"""Get full information on a guild. """Get full information on a guild.
This is a very expensive operation. This is a very expensive operation.
""" """
guild = await self.get_guild(guild_id, user_id) guild = await self.get_guild(guild_id, user_id)
if guild is None:
return None
extra = await self.get_guild_extra(guild_id, user_id, large_count) extra = await self.get_guild_extra(guild_id, user_id, large_count)
return {**guild, **extra} return {**guild, **extra}
@ -729,12 +787,13 @@ class Storage:
return res return res
async def _inject_author(self, res): async def _inject_author(self, res: dict):
"""Inject a pseudo-user object when the message is made by a webhook.""" """Inject a pseudo-user object when the message is made by a webhook."""
author_id, webhook_id = res['author_id'], res['webhook_id'] author_id, webhook_id = res['author_id'], res['webhook_id']
if author_id is not None: if author_id is not None:
res['author'] = await self.get_user(res['author_id']) res['author'] = await self.get_user(res['author_id'])
res.pop('webhook_id')
elif webhook_id is not None: elif webhook_id is not None:
res['author'] = { res['author'] = {
'id': webhook_id, 'id': webhook_id,
@ -744,7 +803,8 @@ class Storage:
res.pop('author_id') res.pop('author_id')
async def get_message(self, message_id: int, user_id=None) -> Dict: async def get_message(self, message_id: int,
user_id: Optional[int] = None) -> Optional[Dict]:
"""Get a single message's payload.""" """Get a single message's payload."""
row = await self.fetchrow_with_json(""" row = await self.fetchrow_with_json("""
SELECT id::text, channel_id::text, author_id, webhook_id, content, SELECT id::text, channel_id::text, author_id, webhook_id, content,
@ -755,7 +815,7 @@ class Storage:
""", message_id) """, message_id)
if not row: if not row:
return return None
res = dict(row) res = dict(row)
res['nonce'] = str(res['nonce']) res['nonce'] = str(res['nonce'])
@ -813,8 +873,12 @@ class Storage:
res['attachments'] = await self.get_attachments(message_id) res['attachments'] = await self.get_attachments(message_id)
# TODO: res['member'] for partial member data # if message is not from a dm, guild_id is None and so, _member_basic
# of the author # will just return None
res['member'] = await self._member_basic_with_roles(guild_id, user_id)
if res['member'] is None:
res.pop('member')
pin_id = await self.db.fetchval(""" pin_id = await self.db.fetchval("""
SELECT message_id SELECT message_id
@ -832,7 +896,7 @@ class Storage:
return res return res
async def get_invite(self, invite_code: str) -> dict: async def get_invite(self, invite_code: str) -> Optional[Dict]:
"""Fetch invite information given its code.""" """Fetch invite information given its code."""
invite = await self.db.fetchrow(""" invite = await self.db.fetchrow("""
SELECT code, guild_id, channel_id SELECT code, guild_id, channel_id
@ -861,6 +925,10 @@ class Storage:
dinv['guild'] = {} dinv['guild'] = {}
chan = await self.get_channel(invite['channel_id']) chan = await self.get_channel(invite['channel_id'])
if chan is None:
return None
dinv['channel'] = { dinv['channel'] = {
'id': chan['id'], 'id': chan['id'],
'name': chan['name'], 'name': chan['name'],
@ -893,7 +961,8 @@ class Storage:
'approximate_member_count': len(mids), 'approximate_member_count': len(mids),
} }
async def get_invite_metadata(self, invite_code: str) -> Dict[str, Any]: async def get_invite_metadata(self,
invite_code: str) -> Optional[Dict[str, Any]]:
"""Fetch invite metadata (max_age and friends).""" """Fetch invite metadata (max_age and friends)."""
invite = await self.db.fetchrow(""" invite = await self.db.fetchrow("""
SELECT code, inviter, created_at, uses, SELECT code, inviter, created_at, uses,
@ -903,7 +972,7 @@ class Storage:
""", invite_code) """, invite_code)
if invite is None: if invite is None:
return return None
dinv = dict_(invite) dinv = dict_(invite)
inviter = await self.get_user(invite['inviter']) inviter = await self.get_user(invite['inviter'])
@ -911,17 +980,18 @@ class Storage:
return dinv return dinv
async def get_dm(self, dm_id: int, user_id: int = None): async def get_dm(self, dm_id: int, user_id: int = None) -> Optional[Dict]:
"""Get a DM channel."""
dm_chan = await self.get_channel(dm_id) dm_chan = await self.get_channel(dm_id)
if user_id: if user_id and dm_chan:
dm_chan['recipients'] = _filter_recipients( dm_chan['recipients'] = _filter_recipients(
dm_chan['recipients'], user_id dm_chan['recipients'], str(user_id)
) )
return dm_chan return dm_chan
async def guild_from_channel(self, channel_id: int): async def guild_from_channel(self, channel_id: int) -> int:
"""Get the guild id coming from a channel id.""" """Get the guild id coming from a channel id."""
return await self.db.fetchval(""" return await self.db.fetchval("""
SELECT guild_id SELECT guild_id
@ -944,7 +1014,7 @@ class Storage:
return parties[0] return parties[0]
async def get_emoji(self, emoji_id: int) -> Dict: async def get_emoji(self, emoji_id: int) -> Optional[Dict[str, Any]]:
"""Get a single emoji.""" """Get a single emoji."""
row = await self.db.fetchrow(""" row = await self.db.fetchrow("""
SELECT id::text, name, animated, managed, SELECT id::text, name, animated, managed,
@ -954,7 +1024,7 @@ class Storage:
""", emoji_id) """, emoji_id)
if not row: if not row:
return return None
drow = dict(row) drow = dict(row)
@ -993,3 +1063,12 @@ class Storage:
""", role_id) """, role_id)
return [r['id'] for r in rows] return [r['id'] for r in rows]
async def all_voice_regions(self) -> List[Dict[str, Any]]:
"""Return a list of all voice regions."""
rows = await self.db.fetch("""
SELECT id, name, vip, deprecated, custom
FROM voice_regions
""")
return list(map(dict, rows))

View File

@ -19,14 +19,15 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import asyncio import asyncio
import json import json
from typing import Any, Iterable, Optional, Sequence
from logbook import Logger from logbook import Logger
from typing import Any
from quart.json import JSONEncoder from quart.json import JSONEncoder
log = Logger(__name__) log = Logger(__name__)
async def async_map(function, iterable) -> list: async def async_map(function, iterable: Iterable) -> list:
"""Map a coroutine to an iterable.""" """Map a coroutine to an iterable."""
res = [] res = []
@ -51,7 +52,7 @@ def dict_get(mapping, key, default):
return mapping.get(key) or default return mapping.get(key) or default
def index_by_func(function, indexable: iter) -> int: def index_by_func(function, indexable: Sequence[Any]) -> Optional[int]:
"""Search in an idexable and return the index number """Search in an idexable and return the index number
for an iterm that has func(item) = True.""" for an iterm that has func(item) = True."""
for index, item in enumerate(indexable): for index, item in enumerate(indexable):
@ -66,7 +67,7 @@ def _u(val):
return val % 0x100000000 return val % 0x100000000
def mmh3(key: str, seed: int = 0): def mmh3(inp_str: str, seed: int = 0):
"""MurMurHash3 implementation. """MurMurHash3 implementation.
This seems to match Discord's JavaScript implementaiton. This seems to match Discord's JavaScript implementaiton.
@ -74,7 +75,7 @@ def mmh3(key: str, seed: int = 0):
Based off Based off
https://github.com/garycourt/murmurhash-js/blob/master/murmurhash3_gc.js https://github.com/garycourt/murmurhash-js/blob/master/murmurhash3_gc.js
""" """
key = [ord(c) for c in key] key = [ord(c) for c in inp_str]
remainder = len(key) & 3 remainder = len(key) & 3
bytecount = len(key) - remainder bytecount = len(key) - remainder
@ -160,7 +161,7 @@ async def pg_set_json(con):
) )
def yield_chunks(input_list: list, chunk_size: int): def yield_chunks(input_list: Sequence[Any], chunk_size: int):
"""Yield successive n-sized chunks from l. """Yield successive n-sized chunks from l.
Taken from https://stackoverflow.com/a/312464. Taken from https://stackoverflow.com/a/312464.

200
litecord/voice/lvsp_conn.py Normal file
View File

@ -0,0 +1,200 @@
"""
Litecord
Copyright (C) 2018-2019 Luna Mendes
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, version 3 of the License.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
import json
import asyncio
from typing import Dict
import websockets
from logbook import Logger
from litecord.voice.lvsp_opcodes import OPCodes as OP, InfoTable, InfoReverse
log = Logger(__name__)
class LVSPConnection:
"""Represents a single LVSP connection."""
def __init__(self, lvsp, region: str, hostname: str):
self.lvsp = lvsp
self.app = lvsp.app
self.region = region
self.hostname = hostname
self.conn = None
self.health = 0.5
self._hb_task = None
self._hb_interval = None
@property
def _log_id(self):
return f'region={self.region} hostname={self.hostname}'
async def send(self, payload):
"""Send a payload down the websocket."""
msg = json.dumps(payload)
await self.conn.send(msg)
async def recv(self):
"""Receive a payload."""
msg = await self.conn.recv()
msg = json.dumps(msg)
return msg
async def send_op(self, opcode: int, data: dict):
"""Send a message with an OP code included"""
await self.send({
'op': opcode,
'd': data
})
async def send_info(self, info_type: str, info_data: Dict):
"""Send an INFO message down the websocket."""
await self.send({
'op': OP.info,
'd': {
'type': InfoTable[info_type.upper()],
'data': info_data
}
})
async def _heartbeater(self, hb_interval: int):
try:
await asyncio.sleep(hb_interval)
# TODO: add self._seq
await self.send_op(OP.heartbeat, {
's': 0
})
# give the server 300 milliseconds to reply.
await asyncio.sleep(300)
await self.conn.close(4000, 'heartbeat timeout')
except asyncio.CancelledError:
pass
def _start_hb(self):
self._hb_task = self.app.loop.create_task(
self._heartbeater(self._hb_interval)
)
def _stop_hb(self):
self._hb_task.cancel()
async def _handle_0(self, msg):
"""Handle HELLO message."""
data = msg['d']
# nonce = data['nonce']
self._hb_interval = data['heartbeat_interval']
# TODO: send identify
async def _update_health(self, new_health: float):
"""Update the health value of a given voice server."""
self.health = new_health
await self.app.db.execute("""
UPDATE voice_servers
SET health = $1
WHERE hostname = $2
""", new_health, self.hostname)
async def _handle_3(self, msg):
"""Handle READY message.
We only start heartbeating after READY.
"""
await self._update_health(msg['health'])
self._start_hb()
async def _handle_5(self, msg):
"""Handle HEARTBEAT_ACK."""
self._stop_hb()
await self._update_health(msg['health'])
self._start_hb()
async def _handle_6(self, msg):
"""Handle INFO messages."""
info = msg['d']
info_type_str = InfoReverse[info['type']].lower()
try:
info_handler = getattr(self, f'_handle_info_{info_type_str}')
except AttributeError:
return
await info_handler(info['data'])
async def _handle_info_channel_assign(self, data: dict):
"""called by the server once we got a channel assign."""
try:
channel_id = data['channel_id']
channel_id = int(channel_id)
except (TypeError, ValueError):
return
try:
guild_id = data['guild_id']
guild_id = int(guild_id)
except (TypeError, ValueError):
guild_id = None
main_key = guild_id if guild_id is not None else channel_id
await self.lvsp.assign(main_key, self.hostname)
async def _loop(self):
while True:
msg = await self.recv()
try:
opcode = msg['op']
handler = getattr(self, f'_handle_{opcode}')
await handler(msg)
except (KeyError, AttributeError):
# TODO: error codes in LVSP
raise Exception('invalid op code')
async def start(self):
"""Try to start a websocket connection."""
try:
self.conn = await websockets.connect(f'wss://{self.hostname}')
except Exception:
log.exception('failed to start lvsp conn to {}', self.hostname)
async def run(self):
"""Start the websocket."""
await self.start()
try:
if not self.conn:
log.error('failed to start lvsp connection, stopping')
return
await self._loop()
except websockets.exceptions.ConnectionClosed as err:
log.warning('conn close, {}, err={}', self._log_id, err)
# except WebsocketClose as err:
# log.warning('ws close, state={} err={}', self.state, err)
# await self.conn.close(code=err.code, reason=err.reason)
except Exception as err:
log.exception('An exception has occoured. {}', self._log_id)
await self.conn.close(code=4000, reason=repr(err))

View File

@ -0,0 +1,146 @@
"""
Litecord
Copyright (C) 2018-2019 Luna Mendes
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, version 3 of the License.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
from typing import Optional
from collections import defaultdict
from logbook import Logger
from litecord.voice.lvsp_conn import LVSPConnection
log = Logger(__name__)
class LVSPManager:
"""Manager class for Litecord Voice Server Protocol (LVSP) connections.
Spawns :class:`LVSPConnection` as needed, etc.
"""
def __init__(self, app, voice):
self.app = app
self.voice = voice
# map servers to LVSPConnection
self.conns = {}
# maps regions to server hostnames
self.servers = defaultdict(list)
# maps Union[GuildID, DMId, GroupDMId] to server hostnames
self.assign = {}
self.app.loop.create_task(self._spawn())
async def _spawn(self):
"""Spawn LVSPConnection for each region."""
regions = await self.app.db.fetch("""
SELECT id
FROM voice_regions
WHERE deprecated = false
""")
regions = [r['id'] for r in regions]
if not regions:
log.warning('no regions are setup')
return
for region in regions:
self.app.loop.create_task(
self._spawn_region(region)
)
async def _spawn_region(self, region: str):
"""Spawn a region. Involves fetching all the hostnames
for the regions and spawning a LVSPConnection for each."""
servers = await self.app.db.fetch("""
SELECT hostname
FROM voice_servers
WHERE region_id = $1
""", region)
if not servers:
log.warning('region {} does not have servers', region)
return
servers = [r['hostname'] for r in servers]
self.servers[region] = servers
for hostname in servers:
conn = LVSPConnection(self, region, hostname)
self.conns[hostname] = conn
self.app.loop.create_task(
conn.run()
)
async def del_conn(self, conn):
"""Delete a connection from the connection pool."""
try:
self.servers[conn.region].remove(conn.hostname)
except KeyError:
pass
try:
self.conns.pop(conn.hostname)
except KeyError:
pass
async def guild_region(self, guild_id: int) -> Optional[str]:
"""Return the voice region of a guild."""
return await self.app.db.fetchval("""
SELECT region
FROM guilds
WHERE id = $1
""", guild_id)
def get_health(self, hostname: str) -> float:
"""Get voice server health, given hostname."""
try:
conn = self.conns[hostname]
except KeyError:
return -1
return conn.health
async def get_guild_server(self, guild_id: int) -> Optional[str]:
"""Get a voice server for the given guild, assigns
one if there isn't any"""
try:
hostname = self.assign[guild_id]
except KeyError:
region = await self.guild_region(guild_id)
# sort connected servers by health
sorted_servers = sorted(
self.servers[region],
key=self.get_health
)
try:
hostname = sorted_servers[0]
except IndexError:
return None
return hostname
async def assign_conn(self, key: int, hostname: str):
"""Assign a connection to a given key in the assign map"""
self.assign[key] = hostname

View File

@ -0,0 +1,41 @@
"""
Litecord
Copyright (C) 2018-2019 Luna Mendes
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, version 3 of the License.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
class OPCodes:
"""LVSP OP codes."""
hello = 0
identify = 1
resume = 2
ready = 3
heartbeat = 4
heartbeat_ack = 5
info = 6
InfoTable = {
'CHANNEL_REQ': 0,
'CHANNEL_ASSIGN': 1,
'CHANNEL_UPDATE': 2,
'CHANNEL_DESTROY': 3,
'VST_CREATE': 4,
'VST_UPDATE': 5,
'VST_LEAVE': 6,
}
InfoReverse = {v: k for k, v in InfoTable.items()}

258
litecord/voice/manager.py Normal file
View File

@ -0,0 +1,258 @@
"""
Litecord
Copyright (C) 2018-2019 Luna Mendes
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, version 3 of the License.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
from typing import Tuple, Dict, List
from collections import defaultdict
from dataclasses import fields
from logbook import Logger
from litecord.permissions import get_permissions
from litecord.enums import ChannelType, VOICE_CHANNELS
from litecord.voice.state import VoiceState
from litecord.voice.lvsp_manager import LVSPManager
VoiceKey = Tuple[int, int]
log = Logger(__name__)
def _construct_state(state_dict: dict) -> VoiceState:
"""Create a VoiceState instance out of a dictionary with the
VoiceState fields as keys."""
state_fields = fields(VoiceState)
args = [state_dict[field.name] for field in state_fields]
return VoiceState(*args)
class VoiceManager:
"""Main voice manager class."""
def __init__(self, app):
self.app = app
# double dict, first key is guild/channel id, second key is user id
self.states = defaultdict(dict)
self.lvsp = LVSPManager(app, self)
# TODO: map channel ids to voice servers
async def can_join(self, user_id: int, channel_id: int) -> int:
"""Return if a user can join a channel."""
channel = await self.app.storage.get_channel(channel_id)
ctype = ChannelType(channel['type'])
if ctype not in VOICE_CHANNELS:
return
states = await self.app.voice.state_count(channel_id)
# get_permissions returns ALL_PERMISSIONS when
# the channel isn't from a guild
perms = await get_permissions(
user_id, channel_id, storage=self.app.storage
)
# hacky user_limit but should work, as channels not
# in guilds won't have that field.
is_full = states >= channel.get('user_limit', 100)
is_bot = (await self.app.storage.get_user(user_id))['bot']
is_manager = perms.bits.manage_channels
# if the channel is full AND:
# - user is not a bot
# - user is not manage channels
# then it fails
if not is_bot and not is_manager and is_full:
return
# all good
return True
async def state_count(self, channel_id: int) -> int:
"""Get the current amount of voice states in a channel."""
return len(self.states[channel_id])
async def fetch_states(self, channel_id: int) -> Dict[int, VoiceState]:
"""Fetch the states of the given channel."""
# since the state key is (user_id, guild_id | channel_id), we need
# to determine which kind of search we want to do.
guild_id = await self.app.storage.guild_from_channel(channel_id)
# if there isn't a guild for the channel, it is a dm or group dm.
# those are simple to handle.
if not guild_id:
return dict(self.states[channel_id])
# guild states hold a dict mapping user ids to guild states,
# same as channels, thats the structure.
guild_states = self.states[guild_id]
res = {}
# iterate over all users with states and add the channel matches
# into res
for user_id, state in guild_states.items():
if state.channel_id == channel_id:
res[user_id] = state
return res
async def get_state(self, voice_key: VoiceKey) -> VoiceState:
"""Get a single VoiceState for a user in a channel. Returns None
if no VoiceState is found."""
user_id, sec_key_id = voice_key
try:
return self.states[sec_key_id][user_id]
except KeyError:
return None
async def del_state(self, voice_key: VoiceKey):
"""Delete a given voice state."""
user_id, sec_key_id = voice_key
try:
# TODO: tell that to the voice server of the channel.
self.states[sec_key_id].pop(user_id)
except KeyError:
pass
async def update_state(self, state: VoiceState, prop: dict):
"""Update a state in a channel"""
# construct a new state based on the old one + properties
new_state_dict = dict(state.as_json)
for field in prop:
# NOTE: this should not happen, ever.
if field in ('channel_id', 'user_id'):
raise ValueError('properties are updating channel or user')
new_state_dict[field] = prop[field]
new_state = _construct_state(new_state_dict)
# TODO: dispatch to voice server
self.states[state.key][state.user_id] = new_state
async def move_channels(self, old_voice_key: VoiceKey, channel_id: int):
"""Move a user between channels."""
await self.del_state(old_voice_key)
await self.create_state(old_voice_key, {'channel_id': channel_id})
async def _lvsp_info_guild(self, guild_id, info_type, info_data):
hostname = await self.lvsp.get_guild_server(guild_id)
if hostname is None:
log.error('no voice server for guild id {}', guild_id)
return
conn = self.lvsp.get_conn(hostname)
await conn.send_info(info_type, info_data)
async def _create_ctx_guild(self, guild_id, channel_id):
await self._lvsp_info_guild(guild_id, 'CHANNEL_REQ', {
'guild_id': str(guild_id),
'channel_id': str(channel_id),
})
async def _start_voice_guild(self, voice_key: VoiceKey, data: dict):
"""Start a voice context in a guild."""
user_id, guild_id = voice_key
channel_id = int(data['channel_id'])
existing_states = self.states[voice_key]
channel_exists = any(
state.channel_id == channel_id for state in existing_states
)
if not channel_exists:
await self._create_ctx_guild(guild_id, channel_id)
await self._lvsp_info_guild(guild_id, 'VST_CREATE', {
'user_id': str(user_id),
'guild_id': str(guild_id),
'channel_id': str(channel_id),
})
async def create_state(self, voice_key: VoiceKey, data: dict):
"""Creates (or tries to create) a voice state.
Depending on the VoiceKey given, it will use the guild's voice
region or assign one based on the starter of a call, or the owner of
a Group DM.
Once a region is assigned, it'll choose the best voice server
and send a request to it.
"""
# TODO: handle CALL events.
# compare if this voice key is for a guild or a channel
_uid, id2 = voice_key
guild = await self.app.storage.get_guild(id2)
# if guild not found, then we are dealing with a dm or group dm
if not guild:
ctype = await self.app.storage.get_chan_type(id2)
ctype = ChannelType(ctype)
if ctype == ChannelType.GROUP_DM:
# await self._start_voice_dm(voice_key)
pass
elif ctype == ChannelType.DM:
# await self._start_voice_gdm(voice_key)
pass
return
# if guild found, then data.channel_id exists, and we treat it
# as a guild
await self._start_voice_guild(voice_key, data)
async def leave_all(self, user_id: int) -> int:
"""Leave all voice channels."""
# iterate over every state finding matches
# NOTE: we copy the current states dict since we're modifying
# on iteration. this is SLOW.
# TODO: better solution instead of copying, maybe we can generate
# a list of tasks to run that actually do the deletion by themselves
# instead of us generating a delete. then only start running them later
# on.
for sec_key_id, states in dict(self.states).items():
for state in states:
if state.user_id != user_id:
continue
await self.del_state((user_id, sec_key_id))
async def leave(self, guild_id: int, user_id: int):
"""Make a user leave a channel IN A GUILD."""
await self.del_state((guild_id, user_id))
async def voice_server_list(self, region: str) -> List[dict]:
"""Get a list of voice server objects"""
rows = await self.app.db.fetch("""
SELECT hostname, last_health
FROM voice_servers
WHERE region_id = $1
""", region)
return list(map(dict, rows))

61
litecord/voice/state.py Normal file
View File

@ -0,0 +1,61 @@
"""
Litecord
Copyright (C) 2018-2019 Luna Mendes
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, version 3 of the License.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
from dataclasses import dataclass, asdict
@dataclass
class VoiceState:
"""Represents a voice state."""
guild_id: int
channel_id: int
user_id: int
session_id: str
deaf: bool
mute: bool
self_deaf: bool
self_mute: bool
suppressed_by: int
@property
def key(self):
"""Get the second part of a key identifying a state."""
return self.channel_id if self.guild_id is None else self.guild_id
@property
def as_json(self):
"""Return JSON-serializable dict."""
return asdict(self)
def as_json_for(self, user_id: int):
"""Generate JSON-serializable version, given a user ID."""
self_dict = asdict(self)
if user_id is None:
return self_dict
# state.suppress is defined by the user
# that is currently viewing the state.
# a better approach would be actually using
# the suppressed_by field for backend efficiency.
self_dict['suppress'] = user_id == self.suppressed_by
self_dict.pop('suppressed_by')
return self_dict

View File

@ -0,0 +1,37 @@
-- voice region data
-- NOTE: do NOT remove any rows. use deprectated=true and
-- DELETE FROM voice_servers instead.
CREATE TABLE IF NOT EXISTS voice_regions (
-- always lowercase
id text PRIMARY KEY,
-- "Russia", "Brazil", "Antartica", etc
name text NOT NULL,
-- we don't have the concept of vip guilds yet, but better
-- future proof.
vip boolean DEFAULT FALSE,
deprecated boolean DEFAULT FALSE,
-- we don't have the concept of custom regions too. we don't have the
-- concept of official guilds either, but i'm keeping this in
custom boolean DEFAULT FALSE
);
-- voice server pool. when someone wants to connect to voice, we choose
-- a server that is in the same region the guild is too, and choose the one
-- with the best health value
CREATE TABLE IF NOT EXISTS voice_servers (
-- hostname is a reachable url, e.g "brazil2.example.com"
hostname text PRIMARY KEY,
region_id text REFERENCES voice_regions (id),
-- health values are more thoroughly defined in the LVSP documentation
last_health float default 0.5
);
ALTER TABLE guilds DROP COLUMN IF EXISTS region;
ALTER TABLE guilds ADD COLUMN
region text REFERENCES voice_regions (id);

64
run.py
View File

@ -24,7 +24,7 @@ import asyncpg
import logbook import logbook
import logging import logging
import websockets import websockets
from quart import Quart, g, jsonify, request from quart import Quart, jsonify, request
from logbook import StreamHandler, Logger from logbook import StreamHandler, Logger
from logbook.compat import redirect_logging from logbook.compat import redirect_logging
from aiohttp import ClientSession from aiohttp import ClientSession
@ -54,14 +54,17 @@ from litecord.blueprints.user import (
user_settings, user_billing, fake_store user_settings, user_billing, fake_store
) )
from litecord.blueprints.user.billing_job import ( from litecord.blueprints.user.billing_job import payment_job
payment_job
from litecord.blueprints.admin_api import (
voice as voice_admin
) )
from litecord.blueprints.admin_api.voice import guild_region_check
from litecord.ratelimits.handler import ratelimit_handler from litecord.ratelimits.handler import ratelimit_handler
from litecord.ratelimits.main import RatelimitManager from litecord.ratelimits.main import RatelimitManager
from litecord.gateway import websocket_handler
from litecord.errors import LitecordError from litecord.errors import LitecordError
from litecord.gateway.state_manager import StateManager from litecord.gateway.state_manager import StateManager
from litecord.storage import Storage from litecord.storage import Storage
@ -70,6 +73,9 @@ from litecord.dispatcher import EventDispatcher
from litecord.presence import PresenceManager from litecord.presence import PresenceManager
from litecord.images import IconManager from litecord.images import IconManager
from litecord.jobs import JobManager from litecord.jobs import JobManager
from litecord.voice.manager import VoiceManager
from litecord.gateway.gateway import websocket_handler
from litecord.utils import LitecordJSONEncoder from litecord.utils import LitecordJSONEncoder
@ -135,7 +141,9 @@ def set_blueprints(app_):
icons: -1, icons: -1,
attachments: -1, attachments: -1,
nodeinfo: -1, nodeinfo: -1,
static: -1 static: -1,
voice_admin: '/admin/voice'
} }
for bp, suffix in bps.items(): for bp, suffix in bps.items():
@ -232,6 +240,8 @@ def init_app_managers(app_):
app_.storage.presence = app_.presence app_.storage.presence = app_.presence
app_.voice = VoiceManager(app_)
async def api_index(app_): async def api_index(app_):
to_find = {} to_find = {}
@ -290,33 +300,43 @@ async def post_app_start(app_):
# we'll need to start a billing job # we'll need to start a billing job
app_.sched.spawn(payment_job(app_)) app_.sched.spawn(payment_job(app_))
app_.sched.spawn(api_index(app_)) app_.sched.spawn(api_index(app_))
app_.sched.spawn(guild_region_check(app_))
@app.before_serving def start_websocket(host, port, ws_handler) -> asyncio.Future:
async def app_before_serving(): """Start a websocket. Returns the websocket future"""
log.info('opening db')
await init_app_db(app)
g.app = app
g.loop = asyncio.get_event_loop()
app.session = ClientSession()
init_app_managers(app)
# start the websocket, etc
host, port = app.config['WS_HOST'], app.config['WS_PORT']
log.info(f'starting websocket at {host} {port}') log.info(f'starting websocket at {host} {port}')
async def _wrapper(ws, url): async def _wrapper(ws, url):
# We wrap the main websocket_handler # We wrap the main websocket_handler
# so we can pass quart's app object. # so we can pass quart's app object.
await websocket_handler(app, ws, url) await ws_handler(app, ws, url)
ws_future = websockets.serve(_wrapper, host, port) return websockets.serve(_wrapper, host, port)
@app.before_serving
async def app_before_serving():
"""Callback for variable setup.
Also sets up the websocket handlers.
"""
log.info('opening db')
await init_app_db(app)
app.session = ClientSession()
init_app_managers(app)
await post_app_start(app) await post_app_start(app)
await ws_future
# start gateway websocket
# voice websocket is handled by the voice server
ws_fut = start_websocket(
app.config['WS_HOST'], app.config['WS_PORT'],
websocket_handler
)
await ws_fut
@app.after_serving @app.after_serving

View File

@ -318,6 +318,40 @@ CREATE TABLE IF NOT EXISTS user_read_state (
PRIMARY KEY (user_id, channel_id) PRIMARY KEY (user_id, channel_id)
); );
-- voice region data
-- NOTE: do NOT remove any rows. use deprectated=true and
-- DELETE FROM voice_servers instead.
CREATE TABLE IF NOT EXISTS voice_regions (
-- always lowercase
id text PRIMARY KEY,
-- "Russia", "Brazil", "Antartica", etc
name text NOT NULL,
-- we don't have the concept of vip guilds yet, but better
-- future proof.
vip boolean DEFAULT FALSE,
deprecated boolean DEFAULT FALSE,
-- we don't have the concept of custom regions too. we don't have the
-- concept of official guilds either, but i'm keeping this in
custom boolean DEFAULT FALSE
);
-- voice server pool. when someone wants to connect to voice, we choose
-- a server that is in the same region the guild is too, and choose the one
-- with the best health value
CREATE TABLE IF NOT EXISTS voice_servers (
-- hostname is a reachable url, e.g "brazil2.example.com"
hostname text PRIMARY KEY,
region_id text REFERENCES voice_regions (id),
-- health values are more thoroughly defined in the LVSP documentation
last_health float default 0.5
);
CREATE TABLE IF NOT EXISTS guilds ( CREATE TABLE IF NOT EXISTS guilds (
id bigint PRIMARY KEY NOT NULL, id bigint PRIMARY KEY NOT NULL,
@ -326,7 +360,7 @@ CREATE TABLE IF NOT EXISTS guilds (
splash text DEFAULT NULL, splash text DEFAULT NULL,
owner_id bigint NOT NULL REFERENCES users (id), owner_id bigint NOT NULL REFERENCES users (id),
region text NOT NULL, region text NOT NULL REFERENCES voice_regions (id),
/* default no afk channel /* default no afk channel
afk channel is voice-only. afk channel is voice-only.

View File

@ -21,6 +21,7 @@ import asyncio
import sys import sys
import os import os
import socket
import pytest import pytest
# this is very hacky. # this is very hacky.
@ -36,8 +37,10 @@ def _test_app(unused_tcp_port, event_loop):
# reassign an unused tcp port for websockets # reassign an unused tcp port for websockets
# since the config might give a used one. # since the config might give a used one.
main_app.config['WS_PORT'] = unused_tcp_port ws_port = unused_tcp_port
main_app.config['WEBSOCKET_URL'] = f'localhost:{unused_tcp_port}'
main_app.config['WS_PORT'] = ws_port
main_app.config['WEBSOCKET_URL'] = f'localhost:{ws_port}'
# make sure we're calling the before_serving hooks # make sure we're calling the before_serving hooks
event_loop.run_until_complete(main_app.startup()) event_loop.run_until_complete(main_app.startup())