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
1
changelog.d/924.feature
Normal file
@ -0,0 +1 @@
|
||||
Add support for Challenge Hound.
|
@ -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)
|
||||
|
Before Width: | Height: | Size: 760 B After Width: | Height: | Size: 760 B |
Before Width: | Height: | Size: 716 B After Width: | Height: | Size: 716 B |
Before Width: | Height: | Size: 866 B After Width: | Height: | Size: 866 B |
Before Width: | Height: | Size: 887 B After Width: | Height: | Size: 887 B |
BIN
docs/_site/icons/hound.png
Normal file
After Width: | Height: | Size: 706 B |
Before Width: | Height: | Size: 990 B After Width: | Height: | Size: 990 B |
Before Width: | Height: | Size: 543 B After Width: | Height: | Size: 543 B |
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
@ -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');
|
||||
}
|
||||
|
||||
|
@ -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:
|
||||
|
42
docs/setup/challengehound.md
Normal 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.
|
@ -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);
|
||||
|
||||
|
@ -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[];
|
||||
|
183
src/Connections/HoundConnection.ts
Normal 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}`;
|
||||
}
|
||||
}
|
@ -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) {
|
||||
|
@ -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)) : [];
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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[]>;
|
||||
}
|
@ -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
@ -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);
|
||||
}
|
||||
}
|