Add support for Challenge Hound (#924)

* Add challenge hound connection type

* Add config

* Add bridge bindings

* Add reader implementation.

* Obvious renames.

* bit more tidying

* refactor

* fix imports

* fix import

* Start feed reader and recognise service.

* Move to using IDs rather than URLs for better security.

* lint

* Validate that challenge exists.

* Drive-by refactors.

* Add add/remove commands for challenge hound.

* Add challenge hound docs.

* Refactor icons

* add some more activity definitions

* changelog

* cleanup feed work
This commit is contained in:
Will Hunt 2024-04-12 13:38:26 +01:00 committed by GitHub
parent 7f5bde990a
commit 1b5e0a4c21
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 505 additions and 32 deletions

1
changelog.d/924.feature Normal file
View File

@ -0,0 +1 @@
Add support for Challenge Hound.

View File

@ -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)

View File

Before

Width:  |  Height:  |  Size: 760 B

After

Width:  |  Height:  |  Size: 760 B

View File

Before

Width:  |  Height:  |  Size: 716 B

After

Width:  |  Height:  |  Size: 716 B

View File

Before

Width:  |  Height:  |  Size: 866 B

After

Width:  |  Height:  |  Size: 866 B

View File

Before

Width:  |  Height:  |  Size: 887 B

After

Width:  |  Height:  |  Size: 887 B

BIN
docs/_site/icons/hound.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 706 B

View File

Before

Width:  |  Height:  |  Size: 990 B

After

Width:  |  Height:  |  Size: 990 B

View File

Before

Width:  |  Height:  |  Size: 543 B

After

Width:  |  Height:  |  Size: 543 B

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -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');
}

View File

@ -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:

View File

@ -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: <the 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.

View File

@ -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<string, AdminRoom> = 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<HoundPayload, HoundConnection>(
"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);

View File

@ -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<T extends IConnection>(typeT: new (...params : any[]) => T): T[] {
return this.connections.filter((c) => (c instanceof typeT)) as T[];

View File

@ -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<string, unknown>): 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<Record<string, unknown>>, {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<string, unknown> = {}, {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}`;
}
}

View File

@ -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) {

View File

@ -14,6 +14,7 @@ export class MemoryStorageProvider extends MSP implements IBridgeStorageProvider
private storedFiles = new QuickLRU<string, string>({ maxSize: 128 });
private gitlabDiscussionThreads = new Map<string, SerializedGitlabDiscussionThreads>();
private feedGuids = new Map<string, Array<string>>();
private houndActivityIds = new Map<string, Array<string>>();
constructor() {
super();
@ -108,4 +109,20 @@ export class MemoryStorageProvider extends MSP implements IBridgeStorageProvider
public async setGitlabDiscussionThreads(connectionId: string, value: SerializedGitlabDiscussionThreads): Promise<void> {
this.gitlabDiscussionThreads.set(connectionId, value);
}
async storeHoundActivity(url: string, ...ids: string[]): Promise<void> {
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<string[]> {
const existing = this.houndActivityIds.get(url);
return existing ? ids.filter((existingGuid) => existing.includes(existingGuid)) : [];
}
}

View File

@ -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<void> {
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<string[]> {
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);
}
}

View File

@ -28,4 +28,6 @@ export interface IBridgeStorageProvider extends IAppserviceStorageProvider, ISto
storeFeedGuids(url: string, ...guids: string[]): Promise<void>;
hasSeenFeed(url: string): Promise<boolean>;
hasSeenFeedGuids(url: string, ...guids: string[]): Promise<string[]>;
storeHoundActivity(id: string, ...guids: string[]): Promise<void>;
hasSeenHoundActivity(id: string, ...guids: string[]): Promise<string[]>;
}

View File

@ -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;
}

129
src/hound/reader.ts Normal file
View File

@ -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<HoundPayload>({
eventName: "hound.activity",
sender: "HoundReader",
data: {
challengeId,
activity: activity,
}
});
}
await this.storage.storeHoundActivity(challengeId, ...activites.map(a => a.id))
}
public async pollChallenges(): Promise<void> {
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);
}
}