diff --git a/changelog.d/924.feature b/changelog.d/924.feature new file mode 100644 index 00000000..77018621 --- /dev/null +++ b/changelog.d/924.feature @@ -0,0 +1 @@ +Add support for Challenge Hound. \ No newline at end of file diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 19f934a5..836b8617 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -9,6 +9,7 @@ - [GitLab](./setup/gitlab.md) - [JIRA](./setup/jira.md) - [Webhooks](./setup/webhooks.md) + - [ChallengeHound](./setup/challengehound.md) - [👤 Usage](./usage.md) - [Dynamic Rooms](./usage/dynamic_rooms.md) - [Authenticating](./usage/auth.md) diff --git a/docs/icons/feeds.png b/docs/_site/icons/feeds.png similarity index 100% rename from docs/icons/feeds.png rename to docs/_site/icons/feeds.png diff --git a/docs/icons/figma.png b/docs/_site/icons/figma.png similarity index 100% rename from docs/icons/figma.png rename to docs/_site/icons/figma.png diff --git a/docs/icons/github.png b/docs/_site/icons/github.png similarity index 100% rename from docs/icons/github.png rename to docs/_site/icons/github.png diff --git a/docs/icons/gitlab.png b/docs/_site/icons/gitlab.png similarity index 100% rename from docs/icons/gitlab.png rename to docs/_site/icons/gitlab.png diff --git a/docs/_site/icons/hound.png b/docs/_site/icons/hound.png new file mode 100644 index 00000000..4d72dbdb Binary files /dev/null and b/docs/_site/icons/hound.png differ diff --git a/docs/icons/jira.png b/docs/_site/icons/jira.png similarity index 100% rename from docs/icons/jira.png rename to docs/_site/icons/jira.png diff --git a/docs/icons/sentry.png b/docs/_site/icons/sentry.png similarity index 100% rename from docs/icons/sentry.png rename to docs/_site/icons/sentry.png diff --git a/docs/icons/webhooks.png b/docs/_site/icons/webhooks.png similarity index 100% rename from docs/icons/webhooks.png rename to docs/_site/icons/webhooks.png diff --git a/docs/_site/style.css b/docs/_site/style.css index c63806ba..eee11b93 100644 --- a/docs/_site/style.css +++ b/docs/_site/style.css @@ -26,33 +26,39 @@ font-weight: 700; } -/* icons for headers */ +/* icons for headers */ +/* We use base64 to avoid having to deal with pathing issues. */ .chapter > li:nth-child(3) > ol:nth-child(1) > li:nth-child(2) strong:after { - content: ' ' url('/matrix-hookshot/latest/icons/feeds.png'); + content: ' ' url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAABGlBMVEXqeBnqeBnqeBnqdxfqdhXvl030tYLxpWbuj0DrfCDqdhbqeBj0uYj////99Oz51rnxpGTrfSHwnlr40bD64cz+9/H//Pr40K/tjDvqeRvqehzrfiLuk0f2w5n99e375dLuk0j40rP0uIbukUPvmlL75NHtizr0uYf1wJTrgCftjj775dP4z63qdxjukEH0toP52Lz++/j40rLvm1T+9e/xomDpdBLqeh3yqW398un1wJP2xp3407Xqex7rfSLxpGXxo2Pyqm7++/n98+vvlUv++PPtjTzxp2j52b70t4TrfyT75ND//v7xomHxqGv//fz//Pvxpmf0uIX40bH41LXzs33rfiPyqWzukUT0u4vxp2nwoV/1vI3vl04j+OXeAAAAAnRSTlNd4NmMDFkAAAABYktHRA32tGH1AAAAB3RJTUUH5gQWESsnQqV9+QAAAM1JREFUGNNjYGBkQgKMDEA+MwsLM5IIEzMrGzsHJxc3VISBiYWHl5ePX0CQGSbALCQsIsorJi7BDBVgkpSSlpGV45VXYIaqYFdUUpZUkedVVWOGmKEONENDU0ubV4eTGySgq6dvYMhrpGlsYmjKBTaD2czcwpLXStKa18YWJMBtZ+9g6+jkrOfiaunGDBTgchczFLD14PX08vbxZQEJ+PkHBJoF8Qabh/CGggS4w8L97LgiIqMko2NigVoYmbi5gB7R5WJmYgZisHdRvA8A4+sWdRXffIcAAAAldEVYdGRhdGU6Y3JlYXRlADIwMjItMDQtMjJUMTc6NDI6MDcrMDA6MDAJz20rAAAAJXRFWHRkYXRlOm1vZGlmeQAyMDIyLTA0LTIyVDE3OjQyOjA3KzAwOjAweJLVlwAAAABJRU5ErkJggg=='); } .chapter > li:nth-child(3) > ol:nth-child(1) > li:nth-child(3) strong:after { - content: ' ' url('/matrix-hookshot/latest/icons/figma.png'); + content: ' ' url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABcWlDQ1BpY2MAACiRdZE9S8NQFIbftkpFKx3qIOKQoYpDC0VFOmoduhQptYJVl+Q2aYUkDTcpUlwFF4eCg+ji1+A/0FVwVRAERRBx8gf4tUiJ5zaFFmlPuDkP7z3v4d5zAX9GZ4bdlwAM0+G5dEpaLaxJwXf4EEIEs0jKzLYWstkMesbPI1VTPMRFr951XWOoqNoM8A0QzzGLO8TzxJktxxK8RzzCynKR+IQ4xumAxLdCVzx+E1zy+Eswz+cWAb/oKZU6WOlgVuYG8RRx1NCrrHUecZOQaq4sUx6jNQ4bOaSRggQFVWxCh4M4ZZNm1t2XaPqWUCEPo7+FGjg5SiiTN0ZqlbqqlDXSVfp01MTc/8/T1mamve6hFND/6rqfE0BwH2jUXff31HUbZ0DgBbg22/4KzSn5TXq9rUWPgfAOcHnT1pQD4GoXGH22ZC43pQAtv6YBHxfAcAGI3AOD696sWvs4fwLy2/REd8DhETBJ9eGNPzxOaCbSjI5SAAAACXBIWXMAAAsSAAALEgHS3X78AAABAUlEQVQ4T6WSPRIBQRCFu3sTCSVUiswF3GBUCclFbDmCXIDYGTYQkG0gQ5W9AXcQCGUEmDb+p836qfJF+95uv9l51QB/gq/GppJTHlFoHtNnnSyUny+RI+wHpacBQLbYVPO+GZ7DbdiBUXGrybYlAjzEwNa/IAKAORL6B0RAarwS94tF644tnRIHNVaaIES+9lAfImame0Xohebyy3WZ3pcY1NhnhPl9+ExmcvDN8KVYc5rKzvSnEsEpkYgcz0YEMENk64sHrmcjAhojdEp8vTPw8XOJiUVPmdTHJm6LbecbG/EHiUXXN8b7TYxBBBD8uYnfCotDBOyKbVGYBrl1cZwAOfxEBEfXmiUAAAAASUVORK5CYII='); } .chapter > li:nth-child(3) > ol:nth-child(1) > li:nth-child(4) strong:after { - content: ' ' url('/matrix-hookshot/latest/icons/github.png'); + content: ' ' url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABcWlDQ1BpY2MAACiRdZE9S8NQFIbftkpFKx3qIOKQoYpDC8UvHLUOXYqUWsGqS3KbtEKShpsUKa6Ci0PBQXTxa/Af6Cq4KgiCIog4+QP8WqTEc5tCi7Qn3JyH9573cO+5gD+tM8PuSQCG6fBsKimt5tek4Dt8CCGCGUzLzLYWMpk0usbPI1VTPMRFr+51HWOgoNoM8PURzzKLO8TzxOktxxK8RzzESnKB+IQ4xumAxLdCVzx+E1z0+Eswz2UXAb/oKRXbWGljVuIG8QRx1NArrHkecZOQaq4sUx6hNQobWaSQhAQFFWxCh4M4ZZNm1tmXaPiWUCYPo7+FKjg5iiiRN0ZqhbqqlDXSVfp0VMXc/8/T1qYmve6hJND76rqfY0BwH6jXXPf31HXrZ0DgBbg2W/4yzWnum/RaS4seA+Ed4PKmpSkHwNUuMPxsyVxuSAFafk0DPi6AwTwQuQf6171ZNfdx/gTktumJ7oDDI2Cc6sMbfzXVaCPmlJtVAAAACXBIWXMAAC4jAAAuIwF4pT92AAABl0lEQVQ4T5WTvU4CQRDHd5dTEqvDGMHuiBAhBF0KE0veAEo7fQOxsvYJxCdQ38BHwM7ChAU1nB8EOj9ilARjAh433lyYc0HQ+Csu/5mbmZ3NzHI2ASml2f2EA85Ykblgcs4Vd3n51lYn47FcNzDxvee+6b5xuMHKd5f13cAmkcxkJLihKtm/AYy3m41aHLXAD54cJHM41WJHEbzjX4eBtZxea6HL7yCRXgWKuW/UfV9KSstWqk1+KfOmUpVOSm5YTu/DTzbCIi5kPm9SkI6ejGCyLxzHIp/Th6roPr2WySFAbJOehn11UQkMAFNwwQtkTxrTJLwcRVrgnPWf/wU7CKolstmgm98AFyRpb4zuYfDH4dNHOGQlLXd0Oxij18r+AGALZxxionTTUN+FPZLJdclm+0f66QDiOIQiEo1GPGtvJixygwErAoPNxdjS2cvzY5uC56MLD94KxshGmnYtZ/jiul7yNqvg9NxWc7hIf2GE5/xVHgnG9cQroKaNJPRtxWRbnbdR+2+BwAdClX/gvQO8MxamZOQLp/igo1dFFCgAAAAASUVORK5CYII='); } .chapter > li:nth-child(3) > ol:nth-child(1) > li:nth-child(5) strong:after { - content: ' ' url('/matrix-hookshot/latest/icons/gitlab.png'); + content: ' ' url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABbmlDQ1BpY2MAACiRdZE9S0JhFMd/alGU4WBDRIODRYOCFIRj2eAiIWaQ1aLXt0Cvl3uVkNagpUFoiFp6G/oGtQatBUFQBBFNfYDelpDbuSoooefy3PPj/5z/4XnOA/ZIQSkafQEoqmU9Fg55VhNrnoF3bDhxE8SWVAxtIRqN0DN+HqVa4sFv9epd1zWG0xlDAdug8Jyi6WXheeHIVlmzeE94VMkn08Inwj5dDih8a+mpJr9ZnGvyl8V6PLYIdqunJ9fBqQ5W8npReFrYWyxUlNZ5rJs4M+rKsuRxWRMYxAgTwkOKCpsUKOOXrMrMuvsCDd8SJfEo8teooosjR168PlEr0jUjOSt6Rr4CVWvu/+dpZGdnmt2dIeh/Nc3PSRjYh3rNNH9PTbN+Bo4XuFbb/pLMKfgteq2teY/BtQOXN20tdQBXuzD2rCX1ZENyyLJns/BxASMJcN/D0HpzVq19zp8gvi1PdAeHRzAl9a6NP84cZ/OkIlzqAAAACXBIWXMAAAsSAAALEgHS3X78AAABr0lEQVQ4T6VSzUrDQBCebNI2lqCXUqhSsBWaUjxVFASPngWPioJP4ANIH0DER/DoD4iP4FmLiBdB8NKDerEFD1JKbJus32x+mjbUix/szs7km5lvd6JJKTX6B0Tced+steP+NIDnheeoAIInMLnQnwbwjgOrcjW+AhwDZ4f8govF25e3WE6EgNcP3Bx4X6oKDkMaqbkObAIBL8QDbyoJlRuxD2ux8xjAO4i5Zd60z+2qrrF8T12B4Q1+tEN3QN0YWcG05A1MFBd5qqk3cK9sj+4QCQY6cKjXaenZkKggiQo1l7Rw6HNoaNOjcM/tLXIRWKXofimTxpOBjCWdKJmHWCET+wa/waUKCjKomJQdIlfyOMFXuR5YQOh7rxbst/IKZOGDGlN+yev5FB+SVTJWoFRG4TLPlcE/0C7WGWaQpnsiIyOzLFlKJb+LwhaVcG+hpBMaj36koIgC3sRTw20SfTzrQ6GTUaji8VLoXOcMOkXyUchPFGCgyBN6VftNTXRaIr2w7PIjk77vd40jEWCgQx1Cy+a8FDOz0sG12uicnuQpsIK/Vrdh7wwvKvpkPFy/C9SuZFOE3X8AAAAASUVORK5CYII='); } .chapter > li:nth-child(3) > ol:nth-child(1) > li:nth-child(6) strong:after { - content: ' ' url('/matrix-hookshot/latest/icons/jira.png'); + content: ' ' url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABcmlDQ1BpY2MAACiRdZE9S8NQFIbftkpFKx3qIOKQoYpDC0VBxUnr0KVIqRWsuiS3SSskabhJkeIquDgUHEQXvwb/ga6Cq4IgKIKIkz/Ar0VKPLcptEh7ws15eO95D/eeC/jTOjPsngRgmA7PppLSan5NCr7DhxAimMOMzGxrIZNJo2v8PFI1xUNc9Ope1zEGCqrNAF8f8TSzuEM8T5zecizBe8RDrCQXiE+IY5wOSHwrdMXjN8FFj78E81x2EfCLnlKxjZU2ZiVuEE8QRw29wprnETcJqebKMuURWqOwkUUKSUhQUMEmdDiIUzZpZp19iYZvCWXyMPpbqIKTo4gSeWOkVqirSlkjXaVPR1XM/f88bW1q0useSgK9r677OQYE94F6zXV/T123fgYEXoBrs+Uv05xmv0mvtbToMRDeAS5vWppyAFztAsPPlszlhhSg5dc04OMCGMwDkXugf92bVXMf509Abpue6A44PALGqT688QdHJ2grp2cfigAAAAlwSFlzAAALEgAACxIB0t1+/AAAAhJJREFUOE91Uz1vE0EQfbPe3fuIERFKUAgfQobYFBFKzY+ggiBRUqSgpAABLigACapIdFFEQe0iPwBRpEpBgSiQE4ToUIRcIGPsu/XdLbPnj5zP8HRzuzc783bf7BxZawn/QeMFNi3wruyfgBPbsuwsokLYTywSDgzLa2NcFmVPEV+e4JgDtnhqymsTUFHC2hto/MJVNzd/ejDGwPR6UFWvplR4Uwjh+wthDUQ3xindGQnqN84kAp9hrZCeRpqlEDz2u33Egw6GcZxdbDRafrgwzZmRMD7yeyJCRSl4QVCwEMrzhU2zqJgzVwOu+j1+H0xJwnBsQW7GxC0Om5JQ/bm9k6WpNlGEaBDBjcM4EkrrDapgdXH5bIcg7seDAQxbmsXrS+cu1Tl3l01K3rFJQqxLraDSFDZzliDus+5oAKS0t3ThfC7DVTsZKhw+xd6VV/ggDDaksNi2RLsVycf1C0LyxyJD+iOXI2VOIrw4j/j2CF0e9kW7ibcc94CDOk6z9n22ADrwmTBwlf/kAh2JYBKtqpNdcuRFPGxi+9QyVhNBKyT19RHJyJQXZpnALe6WFpMkM9mMaR983IJb/Fl7yU5SIyfL4ObB18fo8Nfmtdeonl48uQGHuX/h+xo69SM6lkqtcEOB5MlNtx+iVwjNMd8Ht5Fx3jOnWWoN359W9p+YI3A4amKHT3+XSQ6oovrl9SL+AhmcsPdAgrGeAAAAAElFTkSuQmCC'); } .chapter > li:nth-child(3) > ol:nth-child(1) > li:nth-child(7) strong:after { - content: ' ' url('/matrix-hookshot/latest/icons/webhooks.png'); + content: ' ' url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABbmlDQ1BpY2MAACiRdZE9S0JhFMd/amGU0ZBDRIODRYOCFERj2eAiIWaQ1XK9+RJc7XKvEtIatDQIDVFLb0PfoNagtSAIiiCiqQ/Q2xJxO48GSti5PPf8+D/nfzj3XHDHDb1od0SgWCpbyVg0sJBeDHhfcOHDD/g13TanE4k4/8bnnVRL3IZVr//r2kbPStbWwdUlPKGbVll4Sji+XjYVb6sZ9IK2InwoHLJkQOErpWca/Kw43+B3xVYqOQNu1TOQb+FMC+sFqyg8KhwsGhX9dx71Jb5saX5O8qCcIWySxIgSIEOFVQzKhCWXZGftfZG6b5Y18ejyNqliiSNPQbwhUSvSNSs5J3pWHoOq2vvffdq58bFGd18UOp8c520YvDvwXXOcryPH+T4GzyNclJr+NdnT5IfotaYWPIC+TTi7bGqZXTjfgoEHU7O0uuSR487l4PUUetPQfwPdS41d/d5zcg+pDflF17C3DyNS37f8A3/sZ8/TN03XAAAACXBIWXMAAAsSAAALEgHS3X78AAAAMnRFWHRSYXcgcHJvZmlsZSB0eXBlIGV4aWYACmV4aWYKICAgICAgIDYKNDU3ODY5NjYwMDAwCvm8oQ4AAABadEVYdFJhdyBwcm9maWxlIHR5cGUgaXB0YwAKaXB0YwogICAgICAyNgo1MDY4NmY3NDZmNzM2ODZmNzAyMDMzMmUzMDAwMzg0MjQ5NGQwNDA0MDAwMDAwMDAwMDAwCsfRadwAAAAjdEVYdFJhdyBwcm9maWxlIHR5cGUgeG1wAAp4bXAKICAgICAgIDAKTRhOWwAAAitJREFUOE+VU71L8lEUfvwqJXFQIRVEQvEPEIdQaBFpEBSXhDYRbBP/h0hIEEQXwaEclIjctMmPUVwEQTAdolREDBTNBDE173mpl37yDu8z3XvPuc99znPO5W22wD/AQjwej3v8C0LuAcN8Psfd3R3a7TYRmEwmuN1uCIW76Tyugs/PT4TDYchkMpyenmK1WiGbzUKpVMLv9+8o4v/abdFoNLBYLHB2doabmxs8PDzQRaam1+tx03cJ+v0+9Ho9arUaRCIR3t/f6eLh4SGGwyE3fZfg6OgIT09PMJvN2NvbI0VyuRwulwsGg4GbTk7/wnq93sRisU0kEqF9Op3exONxTtZf/CjodDp4eXkhkzQaDUnfxuF0Oqn+29tbpFIplMtlMvobRFCtVnF1dYXpdIrRaIRSqYSLiwsik0qlOD8/x2QyoU5UKhWEQiFqNRHMZjNkMhkEAgEMBgMEg0GcnJxApVL9vHJ8fAyHw0Fxr9dLLc7lchQTdrtdYmaO5/N5aLVaWjebTdzf35ORbBbq9TqsVisSiQQ8Hg/FiODg4ABMBWPd398nqUajEcViEWq1mqZQIBCAz+fj+fkZFosF4/EYEomECHhb9s319TX12W63k0Gvr68k8fLykhJbrRbEYjGWyyX5Eo1GqRRGTqPMzEsmkzQobN7Ziz6fDzqdjl55fHxEoVAAU/vx8UF+2Gy2PwoYAa22eHt7IwVMOhesTFaeQqEgNd/Y+Uz/iy/JFyvRO9cdAwAAAABJRU5ErkJggg=='); } +.chapter > li:nth-child(3) > ol:nth-child(1) > li:nth-child(8) strong:after { + content: ' ' url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAOwAAADsAEnxA+tAAAAGXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAk9JREFUOI1tk09IVGEUxX/3vvdmlMYCkTQjJUVBsBJat2tVERZRDa2SClpIBIIKhUggQpCtolULNxHtsqKWGQS10aQggkgkRZM2ko5v5nvfbfEcGxnv+pxzz7l/xMzYtR42tfk4Oo3RiVgAwbIGxWnWl6cZMV+GSZXAWFsjsj5htu+iWCFADDAMBVHEuznCUj+Dv6cBdAf5fke7182PkM2LbAaIggmmhhiIOVyw56gl4QNEBCDcJo/uz1km+1rxrVgI6koee26Re0uxGIvW9KB2NbS4wQm3wy3r2wI+GwzgfSeWRbSwOrO4ce/bfDyfn9qcApjsjf7U56JPZ7rrDoTDK+/LvDTCqIQQXXcRiMQGQd/sj42WwBXvlIEuCfqXCskFhlYeVaZOBWoauzRJmjMlMHVfGV585cKIorI94TAxNKleVhohDlsIUqzT2pkIM8gh2JEnZzOzaSc7jJeXuwuEhSKWAZTIl2pSggdYA955kECkrrp/WSC777sVYw+mGCdk4lAteEAW+l4UbwFMnsq2eqvOkM5g4OcC5uYwAwmbiHVQJBFXcSViSCZRIN3/TgEzE3XjQgQIRnL3/PGGTFft3stlYPta5lpvT8Mbxg9OpVsrC2+fsogfb36KySUAtQSvmRX1/gPChpWSY0RhNxojFt1k6Nfj/w62bGhMn2LPIAER1JJGxM6BXLEw7EY38ZbFLNtXPuWdvzCyuMHwUl41ziPy2VvgTTwmDgs85mU1SNyY5PQkW9arv7GyxlvbkL8dIDWYLlC/+oUbVqqE/AMToAVJZCYDcQAAAABJRU5ErkJggg=='); +} + + .chapter li:nth-child(7) > a:nth-child(1) > strong:after { - content: ' ' url('/matrix-hookshot/latest/icons/sentry.png'); + content: ' ' url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAPCAYAAADtc08vAAAB5klEQVQoU4WSQU7bUBCGZ6wkStogwglwdkikqpEI6/gEhF3SVXoCzA3CCWpOUO9sVvgGeB0jYSlGYodvAFKgrmLk6cwztlKUgHfWe++f///mR/jgO9wfW4g5hfHlxaZruOngqDeaEoIl50hgz2Jvuu7uWgFDH3bqW60HADorHuGvbJF2o8R/ei+yVqD/beQAoR7G7kAe9HvjgG0k4dybfCrQ3/9hoEa3BGCHc1c5OOyNBhridU5k3sResCqy1oGCp5FkvuKpP5UL5Qp2w9gzNwrwpQkD22Vg58beSG/UUKbdzmL3pODSTPjfYlGnFKkcrIBDzv+QPadm/esXHbQ8oBynN3euXWwGT1eBVgJyyLQny+fUaLSbATE1EWlstQbM43eW0UF07yVHvTG7IKdcayVQkIbvkKOZvfxJGu1WVF6URyzi81asEugyo64IKgHJKz8KFOCxWKy1mwYi+Nnir87Zh9IF3spOtVYgbqhnYqW4SHekKBwlYvuBTJPJObfw9SV1OMpjuUYFuI5cNDjB95lkE+U0jmUzio4USN3jas/mrv/mwkakYyXA+R4571tNscPWjSU7YpiWUGe3ESAaSHQh8BQvgG0W76JqHuac8f9v+QpOrQa6BjAoT3KAQJpYbIxHkub/A+GHBVloER6qAAAAAElFTkSuQmCC'); } diff --git a/docs/setup.md b/docs/setup.md index 004c2f71..880cc8cc 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -120,6 +120,7 @@ Each permission set can have a service. The `service` field can be: - `feed` - `figma` - `webhooks` +- `challengehound` - `*`, for any service. The `level` can be: diff --git a/docs/setup/challengehound.md b/docs/setup/challengehound.md new file mode 100644 index 00000000..2995b006 --- /dev/null +++ b/docs/setup/challengehound.md @@ -0,0 +1,42 @@ +# ChallengeHound + +You can configure Hookshot to bridge [ChallengeHound](https://www.challengehound.com/) activites +into Matrix. + +### Getting the API secret. + +Unfortunately, there is no way to directly request a persistent Challenge Hound API token. The +only way to authenticate with the service at present is to login with an email address and receive +a magic token in an email. This is not something Hookshot has the capability to do on it's own. + +In order to extract the token for use with the bridge, login to Challenge Hound. Once logged in, +please locate the local storage via the devtools of your browser. Inside you will find a `ch:user` +entry with a `token` value. That value should be used as the secret for your Hookshot config. + +```yaml +challengeHound: + token: +``` + +This token tends to expire roughly once a month, and for the moment you'll need to manually +replace it. You can also ask Challenge Hound's support for an API key, although this has not +been tested. + +## Usage + +You can add a new challenge hound challenge by command: + +``` +challengehound add https://www.challengehound.com/challenge/abc-def +``` + +and remove it with the same command + +``` +challengehound remove https://www.challengehound.com/challenge/abc-def +```. + +Hookshot will periodically refetch activities from the challenge and send a notice when a new +one is completed. Note that Hookshot uses your configured cache to store seen activities. If +you have not configured Redis caching, it will default to in-memory storage which means activites +**will** repeat on restart. diff --git a/src/Bridge.ts b/src/Bridge.ts index 0e6daed4..311bb827 100644 --- a/src/Bridge.ts +++ b/src/Bridge.ts @@ -41,6 +41,8 @@ import { SetupWidget } from "./Widgets/SetupWidget"; import { FeedEntry, FeedError, FeedReader, FeedSuccess } from "./feeds/FeedReader"; import PQueue from "p-queue"; import * as Sentry from '@sentry/node'; +import { HoundConnection, HoundPayload } from "./Connections/HoundConnection"; +import { HoundReader } from "./hound/reader"; const log = new Logger("Bridge"); @@ -53,6 +55,7 @@ export class Bridge { private github?: GithubInstance; private adminRooms: Map = new Map(); private feedReader?: FeedReader; + private houndReader?: HoundReader; private provisioningApi?: Provisioner; private replyProcessor = new RichRepliesPreprocessor(true); @@ -78,6 +81,7 @@ export class Bridge { public stop() { this.feedReader?.stop(); + this.houndReader?.stop(); this.tokenStore.stop(); this.as.stop(); if (this.queue.stop) this.queue.stop(); @@ -678,6 +682,12 @@ export class Bridge { (c, data) => c.handleFeedError(data), ); + this.bindHandlerToQueue( + "hound.activity", + (data) => connManager.getConnectionsForHoundChallengeId(data.challengeId), + (c, data) => c.handleNewActivity(data.activity) + ); + const queue = new PQueue({ concurrency: 2, }); @@ -785,6 +795,15 @@ export class Bridge { ); } + if (this.config.challengeHound?.token) { + this.houndReader = new HoundReader( + this.config.challengeHound, + this.connectionManager, + this.queue, + this.storage, + ); + } + const webhookHandler = new Webhooks(this.config); this.listener.bindResource('webhooks', webhookHandler.expressRouter); diff --git a/src/ConnectionManager.ts b/src/ConnectionManager.ts index 891f6c08..5f8508c8 100644 --- a/src/ConnectionManager.ts +++ b/src/ConnectionManager.ts @@ -8,7 +8,8 @@ import { Appservice, Intent, StateEvent } from "matrix-bot-sdk"; import { ApiError, ErrCode } from "./api"; import { BridgeConfig, BridgePermissionLevel, GitLabInstance } from "./config/Config"; import { CommentProcessor } from "./CommentProcessor"; -import { ConnectionDeclaration, ConnectionDeclarations, GenericHookConnection, GitHubDiscussionConnection, GitHubDiscussionSpace, GitHubIssueConnection, GitHubProjectConnection, GitHubRepoConnection, GitHubUserSpace, GitLabIssueConnection, GitLabRepoConnection, IConnection, IConnectionState, JiraProjectConnection } from "./Connections"; +import { ConnectionDeclaration, ConnectionDeclarations, GenericHookConnection, GitHubDiscussionConnection, GitHubDiscussionSpace, GitHubIssueConnection, + GitHubProjectConnection, GitHubRepoConnection, GitHubUserSpace, GitLabIssueConnection, GitLabRepoConnection, IConnection, IConnectionState, JiraProjectConnection } from "./Connections"; import { FigmaFileConnection, FeedConnection } from "./Connections"; import { GetConnectionTypeResponseItem } from "./provisioning/api"; import { GitLabClient } from "./Gitlab/Client"; @@ -22,6 +23,7 @@ import BotUsersManager from "./Managers/BotUsersManager"; import { retry, retryMatrixErrorFilter } from "./PromiseUtil"; import Metrics from "./Metrics"; import EventEmitter from "events"; +import { HoundConnection } from "./Connections/HoundConnection"; const log = new Logger("ConnectionManager"); @@ -341,6 +343,10 @@ export class ConnectionManager extends EventEmitter { return this.connections.filter(c => c instanceof FeedConnection && c.feedUrl === url) as FeedConnection[]; } + public getConnectionsForHoundChallengeId(challengeId: string): HoundConnection[] { + return this.connections.filter(c => c instanceof HoundConnection && c.challengeId === challengeId) as HoundConnection[]; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any public getAllConnectionsOfType(typeT: new (...params : any[]) => T): T[] { return this.connections.filter((c) => (c instanceof typeT)) as T[]; diff --git a/src/Connections/HoundConnection.ts b/src/Connections/HoundConnection.ts new file mode 100644 index 00000000..f56cb45b --- /dev/null +++ b/src/Connections/HoundConnection.ts @@ -0,0 +1,183 @@ +import { Intent, StateEvent } from "matrix-bot-sdk"; +import markdownit from "markdown-it"; +import { BaseConnection } from "./BaseConnection"; +import { IConnection, IConnectionState } from "."; +import { Connection, InstantiateConnectionOpts, ProvisionConnectionOpts } from "./IConnection"; +import { CommandError } from "../errors"; + +export interface HoundConnectionState extends IConnectionState { + challengeId: string; +} + +export interface HoundPayload { + activity: HoundActivity, + challengeId: string, +} + +export interface HoundActivity { + id: string; + distance: number; // in meters + duration: number; + elevation: number; + createdAt: string; + activityType: string; + activityName: string; + user: { + id: string; + fullname: string; + fname: string; + lname: string; + } +} + +export interface IChallenge { + id: string; + distance: number; + duration: number; + elevaion: number; +} + +export interface ILeader { + id: string; + fullname: string; + duration: number; + distance: number; + elevation: number; +} + +function getEmojiForType(type: string) { + switch (type) { + case "run": + return "🏃"; + case "virtualrun": + return "👨‍💻🏃"; + case "ride": + case "cycle": + case "cycling": + return "🚴"; + case "mountainbikeride": + return "⛰️🚴"; + case "virtualride": + return "👨‍💻🚴"; + case "walk": + case "hike": + return "🚶"; + case "skateboard": + return "🛹"; + case "virtualwalk": + case "virtualhike": + return "👨‍💻🚶"; + case "alpineski": + return "⛷️"; + case "swim": + return "🏊"; + default: + return "🕴️"; + } +} + +const md = markdownit(); +@Connection +export class HoundConnection extends BaseConnection implements IConnection { + static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.challengehound.activity"; + static readonly LegacyEventType = "uk.half-shot.matrix-challenger.activity"; // Magically import from matrix-challenger + + static readonly EventTypes = [ + HoundConnection.CanonicalEventType, + HoundConnection.LegacyEventType, + ]; + static readonly ServiceCategory = "challengehound"; + + public static getIdFromURL(url: string): string { + const parts = new URL(url).pathname.split('/'); + return parts[parts.length-1]; + } + + public static validateState(data: Record): HoundConnectionState { + // Convert URL to ID. + if (!data.challengeId && data.url && data.url === "string") { + data.challengeId = this.getIdFromURL(data.url); + } + + // Test for v1 uuid. + if (!data.challengeId || typeof data.challengeId !== "string" || /^\w{8}(?:-\w{4}){3}-\w{12}$/.test(data.challengeId)) { + throw Error('Missing or invalid id'); + } + + return { + challengeId: data.challengeId + } + } + + public static createConnectionForState(roomId: string, event: StateEvent>, {config, intent}: InstantiateConnectionOpts) { + if (!config.challengeHound) { + throw Error('Challenge hound is not configured'); + } + return new HoundConnection(roomId, event.stateKey, this.validateState(event.content), intent); + } + + static async provisionConnection(roomId: string, _userId: string, data: Record = {}, {intent, config}: ProvisionConnectionOpts) { + if (!config.challengeHound) { + throw Error('Challenge hound is not configured'); + } + const validState = this.validateState(data); + // Check the event actually exists. + const statusDataRequest = await fetch(`https://api.challengehound.com/challenges/${validState.challengeId}/status`); + if (!statusDataRequest.ok) { + throw new CommandError(`Fetch failed, status ${statusDataRequest.status}`, "Challenge could not be found. Is it active?"); + } + const { challengeName } = await statusDataRequest.json() as {challengeName: string}; + const connection = new HoundConnection(roomId, validState.challengeId, validState, intent); + await intent.underlyingClient.sendStateEvent(roomId, HoundConnection.CanonicalEventType, validState.challengeId, validState); + return { + connection, + stateEventContent: validState, + challengeName, + }; + } + + constructor( + roomId: string, + stateKey: string, + private state: HoundConnectionState, + private readonly intent: Intent) { + super(roomId, stateKey, HoundConnection.CanonicalEventType) + } + + public isInterestedInStateEvent() { + return false; // We don't support state-updates...yet. + } + + public get challengeId() { + return this.state.challengeId; + } + + public get priority(): number { + return this.state.priority || super.priority; + } + + public async handleNewActivity(payload: HoundActivity) { + const distance = `${(payload.distance / 1000).toFixed(2)}km`; + const emoji = getEmojiForType(payload.activityType); + const body = `🎉 **${payload.user.fullname}** completed a ${distance} ${emoji} ${payload.activityType} (${payload.activityName})`; + const content: any = { + body, + format: "org.matrix.custom.html", + formatted_body: md.renderInline(body), + }; + content["msgtype"] = "m.notice"; + content["uk.half-shot.matrix-challenger.activity.id"] = payload.id; + content["uk.half-shot.matrix-challenger.activity.distance"] = Math.round(payload.distance); + content["uk.half-shot.matrix-challenger.activity.elevation"] = Math.round(payload.elevation); + content["uk.half-shot.matrix-challenger.activity.duration"] = Math.round(payload.duration); + content["uk.half-shot.matrix-challenger.activity.user"] = { + "name": payload.user.fullname, + id: payload.user.id, + }; + await this.intent.underlyingClient.sendMessage(this.roomId, content); + } + + public toString() { + return `HoundConnection ${this.challengeId}`; + } +} diff --git a/src/Connections/SetupConnection.ts b/src/Connections/SetupConnection.ts index 9f4893f3..59877fb0 100644 --- a/src/Connections/SetupConnection.ts +++ b/src/Connections/SetupConnection.ts @@ -1,4 +1,3 @@ -// We need to instantiate some functions which are not directly called, which confuses typescript. import { BotCommands, botCommand, compileBotCommands, HelpFunction } from "../BotCommands"; import { CommandConnection } from "./CommandConnection"; import { GenericHookConnection, GenericHookConnectionState, GitHubRepoConnection, JiraProjectConnection, JiraProjectConnectionState } from "."; @@ -15,6 +14,7 @@ import { IConnection, IConnectionState, ProvisionConnectionOpts } from "./IConne import { ApiError, Logger } from "matrix-appservice-bridge"; import { Intent } from "matrix-bot-sdk"; import YAML from 'yaml'; +import { HoundConnection } from "./HoundConnection"; const md = new markdown(); const log = new Logger("SetupConnection"); @@ -72,13 +72,13 @@ export class SetupConnection extends CommandConnection { this.includeTitlesInHelp = false; } - @botCommand("github repo", { help: "Create a connection for a GitHub repository. (You must be logged in with GitHub to do this.)", requiredArgs: ["url"], includeUserId: true, category: "github"}) + @botCommand("github repo", { help: "Create a connection for a GitHub repository. (You must be logged in with GitHub to do this.)", requiredArgs: ["url"], includeUserId: true, category: GitHubRepoConnection.ServiceCategory}) public async onGitHubRepo(userId: string, url: string) { if (!this.provisionOpts.github || !this.config.github) { throw new CommandError("not-configured", "The bridge is not configured to support GitHub."); } - await this.checkUserPermissions(userId, "github", GitHubRepoConnection.CanonicalEventType); + await this.checkUserPermissions(userId, GitHubRepoConnection.ServiceCategory, GitHubRepoConnection.CanonicalEventType); const octokit = await this.provisionOpts.tokenStore.getOctokitForUser(userId); if (!octokit) { throw new CommandError("User not logged in", "You are not logged into GitHub. Start a DM with this bot and use the command `github login`."); @@ -93,13 +93,13 @@ export class SetupConnection extends CommandConnection { await this.client.sendNotice(this.roomId, `Room configured to bridge ${connection.org}/${connection.repo}`); } - @botCommand("gitlab project", { help: "Create a connection for a GitHub project. (You must be logged in with GitLab to do this.)", requiredArgs: ["url"], includeUserId: true, category: "gitlab"}) + @botCommand("gitlab project", { help: "Create a connection for a GitHub project. (You must be logged in with GitLab to do this.)", requiredArgs: ["url"], includeUserId: true, category: GitLabRepoConnection.ServiceCategory}) public async onGitLabRepo(userId: string, url: string) { if (!this.config.gitlab) { throw new CommandError("not-configured", "The bridge is not configured to support GitLab."); } - await this.checkUserPermissions(userId, "gitlab", GitLabRepoConnection.CanonicalEventType); + await this.checkUserPermissions(userId, GitLabRepoConnection.ServiceCategory, GitLabRepoConnection.CanonicalEventType); const {name, instance} = this.config.gitlab.getInstanceByProjectUrl(url) || {}; if (!instance || !name) { @@ -126,7 +126,7 @@ export class SetupConnection extends CommandConnection { } } - private async getJiraProjectSafeUrl(userId: string, urlStr: string) { + private async getJiraProjectSafeUrl(urlStr: string) { const url = new URL(urlStr); const urlParts = /\/projects\/(\w+)\/?(\w+\/?)*$/.exec(url.pathname); const projectKey = urlParts?.[1] || url.searchParams.get('projectKey'); @@ -136,22 +136,22 @@ export class SetupConnection extends CommandConnection { return `https://${url.host}/projects/${projectKey}`; } - @botCommand("jira project", { help: "Create a connection for a JIRA project. (You must be logged in with JIRA to do this.)", requiredArgs: ["url"], includeUserId: true, category: "jira"}) + @botCommand("jira project", { help: "Create a connection for a JIRA project. (You must be logged in with JIRA to do this.)", requiredArgs: ["url"], includeUserId: true, category: JiraProjectConnection.ServiceCategory}) public async onJiraProject(userId: string, urlStr: string) { if (!this.config.jira) { throw new CommandError("not-configured", "The bridge is not configured to support Jira."); } - await this.checkUserPermissions(userId, "jira", JiraProjectConnection.CanonicalEventType); + await this.checkUserPermissions(userId, JiraProjectConnection.ServiceCategory, JiraProjectConnection.CanonicalEventType); await this.checkJiraLogin(userId, urlStr); - const safeUrl = await this.getJiraProjectSafeUrl(userId, urlStr); + const safeUrl = await this.getJiraProjectSafeUrl(urlStr); const res = await JiraProjectConnection.provisionConnection(this.roomId, userId, { url: safeUrl }, this.provisionOpts); this.pushConnections(res.connection); await this.client.sendNotice(this.roomId, `Room configured to bridge Jira project ${res.connection.projectKey}.`); } - @botCommand("jira list project", { help: "Show JIRA projects currently connected to.", category: "jira"}) + @botCommand("jira list project", { help: "Show JIRA projects currently connected to.", category: JiraProjectConnection.ServiceCategory}) public async onJiraListProject() { const projects: JiraProjectConnectionState[] = await this.client.getRoomState(this.roomId).catch((err: any) => { if (err.body.errcode === 'M_NOT_FOUND') { @@ -177,11 +177,11 @@ export class SetupConnection extends CommandConnection { } } - @botCommand("jira remove project", { help: "Remove a connection for a JIRA project.", requiredArgs: ["url"], includeUserId: true, category: "jira"}) + @botCommand("jira remove project", { help: "Remove a connection for a JIRA project.", requiredArgs: ["url"], includeUserId: true, category: JiraProjectConnection.ServiceCategory}) public async onJiraRemoveProject(userId: string, urlStr: string) { - await this.checkUserPermissions(userId, "jira", JiraProjectConnection.CanonicalEventType); + await this.checkUserPermissions(userId, JiraProjectConnection.ServiceCategory, JiraProjectConnection.CanonicalEventType); await this.checkJiraLogin(userId, urlStr); - const safeUrl = await this.getJiraProjectSafeUrl(userId, urlStr); + const safeUrl = await this.getJiraProjectSafeUrl(urlStr); const eventTypes = [ JiraProjectConnection.CanonicalEventType, @@ -207,7 +207,7 @@ export class SetupConnection extends CommandConnection { return this.client.sendHtmlNotice(this.roomId, md.renderInline(`Room no longer bridged to Jira project \`${safeUrl}\`.`)); } - @botCommand("webhook", { help: "Create an inbound webhook.", requiredArgs: ["name"], includeUserId: true, category: "generic"}) + @botCommand("webhook", { help: "Create an inbound webhook.", requiredArgs: ["name"], includeUserId: true, category: GenericHookConnection.ServiceCategory}) public async onWebhook(userId: string, name: string) { if (!this.config.generic?.enabled) { throw new CommandError("not-configured", "The bridge is not configured to support webhooks."); @@ -234,7 +234,7 @@ export class SetupConnection extends CommandConnection { - @botCommand("webhook list", { help: "Show webhooks currently configured.", category: "generic"}) + @botCommand("webhook list", { help: "Show webhooks currently configured.", category: GenericHookConnection.ServiceCategory}) public async onWebhookList() { const webhooks: GenericHookConnectionState[] = await this.client.getRoomState(this.roomId).catch((err: any) => { if (err.body.errcode === 'M_NOT_FOUND') { @@ -263,9 +263,9 @@ export class SetupConnection extends CommandConnection { } } - @botCommand("webhook remove", { help: "Remove a webhook from the room.", requiredArgs: ["name"], includeUserId: true, category: "generic"}) + @botCommand("webhook remove", { help: "Remove a webhook from the room.", requiredArgs: ["name"], includeUserId: true, category: GenericHookConnection.ServiceCategory}) public async onWebhookRemove(userId: string, name: string) { - await this.checkUserPermissions(userId, "generic", GenericHookConnection.CanonicalEventType); + await this.checkUserPermissions(userId, GenericHookConnection.ServiceCategory, GenericHookConnection.CanonicalEventType); const event = await this.client.getRoomStateEvent(this.roomId, GenericHookConnection.CanonicalEventType, name).catch((err: any) => { if (err.body.errcode === 'M_NOT_FOUND') { @@ -284,13 +284,13 @@ export class SetupConnection extends CommandConnection { return this.client.sendHtmlNotice(this.roomId, md.renderInline(`Removed webhook \`${name}\``)); } - @botCommand("figma file", { help: "Bridge a Figma file to the room.", requiredArgs: ["url"], includeUserId: true, category: "figma"}) + @botCommand("figma file", { help: "Bridge a Figma file to the room.", requiredArgs: ["url"], includeUserId: true, category: FigmaFileConnection.ServiceCategory}) public async onFigma(userId: string, url: string) { if (!this.config.figma) { throw new CommandError("not-configured", "The bridge is not configured to support Figma."); } - await this.checkUserPermissions(userId, "figma", FigmaFileConnection.CanonicalEventType); + await this.checkUserPermissions(userId, FigmaFileConnection.ServiceCategory, FigmaFileConnection.CanonicalEventType); const res = /https:\/\/www\.figma\.com\/file\/(\w+).+/.exec(url); if (!res) { @@ -302,13 +302,13 @@ export class SetupConnection extends CommandConnection { return this.client.sendHtmlNotice(this.roomId, md.renderInline(`Room configured to bridge Figma file.`)); } - @botCommand("feed", { help: "Bridge an RSS/Atom feed to the room.", requiredArgs: ["url"], optionalArgs: ["label"], includeUserId: true, category: "feeds"}) + @botCommand("feed", { help: "Bridge an RSS/Atom feed to the room.", requiredArgs: ["url"], optionalArgs: ["label"], includeUserId: true, category: FeedConnection.ServiceCategory}) public async onFeed(userId: string, url: string, label?: string) { if (!this.config.feeds?.enabled) { throw new CommandError("not-configured", "The bridge is not configured to support feeds."); } - await this.checkUserPermissions(userId, "feed", FeedConnection.CanonicalEventType); + await this.checkUserPermissions(userId,FeedConnection.ServiceCategory, FeedConnection.CanonicalEventType); // provisionConnection will check it again, but won't give us a nice CommandError on failure try { @@ -327,7 +327,7 @@ export class SetupConnection extends CommandConnection { return this.client.sendHtmlNotice(this.roomId, md.renderInline(`Room configured to bridge \`${url}\``)); } - @botCommand("feed list", { help: "Show feeds currently subscribed to. Supported formats `json` and `yaml`.", optionalArgs: ["format"], category: "feeds"}) + @botCommand("feed list", { help: "Show feeds currently subscribed to. Supported formats `json` and `yaml`.", optionalArgs: ["format"], category: FeedConnection.ServiceCategory}) public async onFeedList(format?: string) { const useJsonFormat = format?.toLowerCase() === 'json'; const useYamlFormat = format?.toLowerCase() === 'yaml'; @@ -373,7 +373,7 @@ export class SetupConnection extends CommandConnection { @botCommand("feed remove", { help: "Unsubscribe from an RSS/Atom feed.", requiredArgs: ["url"], includeUserId: true, category: "feeds"}) public async onFeedRemove(userId: string, url: string) { - await this.checkUserPermissions(userId, "feed", FeedConnection.CanonicalEventType); + await this.checkUserPermissions(userId, FeedConnection.ServiceCategory, FeedConnection.CanonicalEventType); const event = await this.client.getRoomStateEvent(this.roomId, FeedConnection.CanonicalEventType, url).catch((err: any) => { if (err.body.errcode === 'M_NOT_FOUND') { @@ -389,6 +389,36 @@ export class SetupConnection extends CommandConnection { return this.client.sendHtmlNotice(this.roomId, md.renderInline(`Unsubscribed from \`${url}\``)); } + @botCommand("challenghound add", { help: "Bridge a ChallengeHound challenge to the room.", requiredArgs: ["url"], includeUserId: true, category: "challengehound"}) + public async onChallengeHoundAdd(userId: string, url: string) { + if (!this.config.challengeHound) { + throw new CommandError("not-configured", "The bridge is not configured to support challengeHound."); + } + + await this.checkUserPermissions(userId, HoundConnection.ServiceCategory, HoundConnection.CanonicalEventType); + const {connection, challengeName} = await HoundConnection.provisionConnection(this.roomId, userId, { url }, this.provisionOpts); + this.pushConnections(connection); + return this.client.sendHtmlNotice(this.roomId, md.renderInline(`Room configured to bridge ${challengeName}. Good luck!`)); + } + + @botCommand("challenghound remove", { help: "Unbridge a ChallengeHound challenge.", requiredArgs: ["urlOrId"], includeUserId: true, category: HoundConnection.ServiceCategory}) + public async onChallengeHoundRemove(userId: string, urlOrId: string) { + await this.checkUserPermissions(userId, HoundConnection.ServiceCategory, HoundConnection.CanonicalEventType); + const id = urlOrId.startsWith('http') ? HoundConnection.getIdFromURL(urlOrId) : urlOrId; + const event = await this.client.getRoomStateEvent(this.roomId, HoundConnection.CanonicalEventType, id).catch((err: any) => { + if (err.body.errcode === 'M_NOT_FOUND') { + return null; // not an error to us + } + throw err; + }); + if (!event || Object.keys(event).length === 0) { + throw new CommandError("Invalid feed URL", `Challenge "${id}" is not currently bridged to this room`); + } + + await this.client.sendStateEvent(this.roomId, FeedConnection.CanonicalEventType, id, {}); + return this.client.sendHtmlNotice(this.roomId, md.renderInline(`Unsubscribed from challenge`)); + } + @botCommand("setup-widget", {category: "widget", help: "Open the setup widget in the room"}) public async onSetupWidget() { if (this.config.widgets?.roomSetupWidget === undefined) { diff --git a/src/Stores/MemoryStorageProvider.ts b/src/Stores/MemoryStorageProvider.ts index 52b5bf54..ef514402 100644 --- a/src/Stores/MemoryStorageProvider.ts +++ b/src/Stores/MemoryStorageProvider.ts @@ -14,6 +14,7 @@ export class MemoryStorageProvider extends MSP implements IBridgeStorageProvider private storedFiles = new QuickLRU({ maxSize: 128 }); private gitlabDiscussionThreads = new Map(); private feedGuids = new Map>(); + private houndActivityIds = new Map>(); constructor() { super(); @@ -108,4 +109,20 @@ export class MemoryStorageProvider extends MSP implements IBridgeStorageProvider public async setGitlabDiscussionThreads(connectionId: string, value: SerializedGitlabDiscussionThreads): Promise { this.gitlabDiscussionThreads.set(connectionId, value); } + + async storeHoundActivity(url: string, ...ids: string[]): Promise { + let set = this.houndActivityIds.get(url); + if (!set) { + set = [] + this.houndActivityIds.set(url, set); + } + set.unshift(...ids); + while (set.length > MAX_FEED_ITEMS) { + set.pop(); + } + } + async hasSeenHoundActivity(url: string, ...ids: string[]): Promise { + const existing = this.houndActivityIds.get(url); + return existing ? ids.filter((existingGuid) => existing.includes(existingGuid)) : []; + } } diff --git a/src/Stores/RedisStorageProvider.ts b/src/Stores/RedisStorageProvider.ts index 49fac70f..6e2e5122 100644 --- a/src/Stores/RedisStorageProvider.ts +++ b/src/Stores/RedisStorageProvider.ts @@ -29,6 +29,7 @@ const WIDGET_TOKENS = "widgets.tokens."; const WIDGET_USER_TOKENS = "widgets.user-tokens."; const FEED_GUIDS = "feeds.guids."; +const HOUND_IDS = "feeds.guids."; const log = new Logger("RedisASProvider"); @@ -240,4 +241,25 @@ export class RedisStorageProvider extends RedisStorageContextualProvider impleme } return guids.filter((_guid, index) => res[index][1] !== null); } + + public async storeHoundActivity(url: string, ...guids: string[]): Promise { + const feedKey = `${HOUND_IDS}${url}`; + await this.redis.lpush(feedKey, ...guids); + await this.redis.ltrim(feedKey, 0, MAX_FEED_ITEMS); + } + + public async hasSeenHoundActivity(url: string, ...guids: string[]): Promise { + let multi = this.redis.multi(); + const feedKey = `${HOUND_IDS}${url}`; + + for (const guid of guids) { + multi = multi.lpos(feedKey, guid); + } + const res = await multi.exec(); + if (res === null) { + // Just assume we've seen none. + return []; + } + return guids.filter((_guid, index) => res[index][1] !== null); + } } diff --git a/src/Stores/StorageProvider.ts b/src/Stores/StorageProvider.ts index 50175d75..3fbbec48 100644 --- a/src/Stores/StorageProvider.ts +++ b/src/Stores/StorageProvider.ts @@ -28,4 +28,6 @@ export interface IBridgeStorageProvider extends IAppserviceStorageProvider, ISto storeFeedGuids(url: string, ...guids: string[]): Promise; hasSeenFeed(url: string): Promise; hasSeenFeedGuids(url: string, ...guids: string[]): Promise; + storeHoundActivity(id: string, ...guids: string[]): Promise; + hasSeenHoundActivity(id: string, ...guids: string[]): Promise; } \ No newline at end of file diff --git a/src/config/Config.ts b/src/config/Config.ts index 1926d91a..83288f97 100644 --- a/src/config/Config.ts +++ b/src/config/Config.ts @@ -12,6 +12,7 @@ import { GithubInstance, GITHUB_CLOUD_URL } from "../github/GithubInstance"; import { Logger } from "matrix-appservice-bridge"; import { BridgeConfigCache } from "./sections/cache"; import { BridgeConfigQueue } from "./sections"; +import { DefaultConfigRoot } from "./Defaults"; const log = new Logger("Config"); @@ -450,6 +451,10 @@ export interface BridgeConfigSentry { environment?: string; } +export interface BridgeConfigChallengeHound { + token?: string; +} + export interface BridgeConfigRoot { bot?: BridgeConfigBot; @@ -473,6 +478,7 @@ export interface BridgeConfigRoot { serviceBots?: BridgeConfigServiceBot[]; webhook?: BridgeConfigWebhook; widgets?: BridgeWidgetConfigYAML; + challengeHound?: BridgeConfigChallengeHound; } export class BridgeConfig { @@ -510,6 +516,8 @@ export class BridgeConfig { public readonly figma?: BridgeConfigFigma; @configKey("Configure this to enable RSS/Atom feed support", true) public readonly feeds?: BridgeConfigFeeds; + @configKey("Configure Challenge Hound support", true) + public readonly challengeHound?: BridgeConfigChallengeHound; @configKey("Define profile information for the bot user", true) public readonly bot?: BridgeConfigBot; @configKey("Define additional bot users for specific services", true) @@ -534,6 +542,8 @@ export class BridgeConfig { @hideKey() private readonly bridgePermissions: BridgePermissions; + + constructor(configData: BridgeConfigRoot, env?: {[key: string]: string|undefined}) { this.bridge = configData.bridge; assert.ok(this.bridge); @@ -554,6 +564,7 @@ export class BridgeConfig { this.bot = configData.bot; this.serviceBots = configData.serviceBots; this.metrics = configData.metrics; + this.challengeHound = configData.challengeHound; // TODO: Formalize env support if (env?.CFG_QUEUE_MONOLITHIC && ["false", "off", "no"].includes(env.CFG_QUEUE_MONOLITHIC)) { @@ -756,6 +767,9 @@ remove "useLegacySledStore" from your configuration file, and restart Hookshot. if (this.jira) { services.push("jira"); } + if (this.challengeHound) { + services.push("challengehound"); + } return services; } diff --git a/src/hound/reader.ts b/src/hound/reader.ts new file mode 100644 index 00000000..0d6afc50 --- /dev/null +++ b/src/hound/reader.ts @@ -0,0 +1,129 @@ +import axios from "axios"; +import { ConnectionManager } from "../ConnectionManager"; +import { HoundConnection, HoundPayload, HoundActivity } from "../Connections/HoundConnection"; +import { MessageQueue } from "../MessageQueue"; +import { IBridgeStorageProvider } from "../Stores/StorageProvider"; +import { BridgeConfigChallengeHound } from "../config/Config"; +import { Logger } from "matrix-appservice-bridge"; + +const log = new Logger("HoundReader"); + +export class HoundReader { + private connections: HoundConnection[]; + private challengeIds: string[]; + private timeout?: NodeJS.Timeout; + private shouldRun = true; + private readonly houndClient: axios.AxiosInstance; + + get sleepingInterval() { + return 60000 / (this.challengeIds.length || 1); + } + + constructor( + config: BridgeConfigChallengeHound, + private readonly connectionManager: ConnectionManager, + private readonly queue: MessageQueue, + private readonly storage: IBridgeStorageProvider, + ) { + this.connections = this.connectionManager.getAllConnectionsOfType(HoundConnection); + this.challengeIds = this.connections.map(c => c.challengeId); + this.houndClient = axios.create({ + headers: { + 'Authorization': config.token, + } + }); + + connectionManager.on('new-connection', newConnection => { + if (!(newConnection instanceof HoundConnection)) { + return; + } + if (!this.challengeIds.includes(newConnection.challengeId)) { + log.info(`Connection added, adding "${newConnection.challengeId}" to queue`); + this.challengeIds.push(newConnection.challengeId); + } + }); + connectionManager.on('connection-removed', removed => { + if (!(removed instanceof HoundConnection)) { + return; + } + let shouldKeepUrl = false; + this.connections = this.connections.filter(c => { + // Cheeky reuse of iteration to determine if we should remove this URL. + if (c.connectionId !== removed.connectionId) { + shouldKeepUrl = shouldKeepUrl || c.challengeId === removed.challengeId; + return true; + } + return false; + }); + if (shouldKeepUrl) { + log.info(`Connection removed, but not removing "${removed.challengeId}" as it is still in use`); + return; + } + log.info(`Connection removed, removing "${removed.challengeId}" from queue`); + this.challengeIds = this.challengeIds.filter(u => u !== removed.challengeId) + }); + + log.debug('Loaded challenge IDs:', [...this.challengeIds].join(', ')); + void this.pollChallenges(); + } + + public stop() { + this.shouldRun = false; + if (this.timeout) { + clearTimeout(this.timeout); + } + } + + public async poll(challengeId: string) { + const resAct = await this.houndClient.get(`https://api.challengehound.com/challenges/${challengeId}/activities?limit=10`); + const activites = resAct.data as HoundActivity[]; + const seen = await this.storage.hasSeenHoundActivity(challengeId, ...activites.map(a => a.id)); + for (const activity of activites) { + if (seen.includes(activity.id)) { + continue; + } + this.queue.push({ + eventName: "hound.activity", + sender: "HoundReader", + data: { + challengeId, + activity: activity, + } + }); + } + await this.storage.storeHoundActivity(challengeId, ...activites.map(a => a.id)) + } + + public async pollChallenges(): Promise { + log.debug(`Checking for updates`); + + const fetchingStarted = Date.now(); + + const challengeId = this.challengeIds.pop(); + let sleepFor = this.sleepingInterval; + + if (challengeId) { + try { + await this.poll(challengeId); + const elapsed = Date.now() - fetchingStarted; + sleepFor = Math.max(this.sleepingInterval - elapsed, 0); + log.debug(`Activity fetching took ${elapsed / 1000}s, sleeping for ${sleepFor / 1000}s`); + + if (elapsed > this.sleepingInterval) { + log.warn(`It took us longer to update the activities than the expected interval`); + } + } finally { + this.challengeIds.splice(0, 0, challengeId); + } + } else { + log.debug(`No activites available to poll`); + } + + this.timeout = setTimeout(() => { + if (!this.shouldRun) { + return; + } + void this.pollChallenges(); + }, sleepFor); + } +} \ No newline at end of file