forked from lug/matterbridge
Compare commits
908 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afcd362cd1 | ||
|
|
0452be0cb3 | ||
|
|
1624f10773 | ||
|
|
8764be7461 | ||
|
|
5dd15ef8e7 | ||
|
|
4ac6366706 | ||
|
|
adc0912efa | ||
|
|
536823ce55 | ||
|
|
207cd24edb | ||
|
|
b039da1eba | ||
|
|
8fcd0f3b6f | ||
|
|
16fde6935c | ||
|
|
9592cff9fa | ||
|
|
109148988c | ||
|
|
cf13fff7d2 | ||
|
|
a9d8ac8bc0 | ||
|
|
1a4717b366 | ||
|
|
6cadf12260 | ||
|
|
19d47784bd | ||
|
|
b89102c5fc | ||
|
|
4f20ebead3 | ||
|
|
a9f89dbc64 | ||
|
|
58ea1e07d2 | ||
|
|
6de4c7e971 | ||
|
|
03dc51ffa2 | ||
|
|
aef2dcdfdd | ||
|
|
0494119bf4 | ||
|
|
0a17e21119 | ||
|
|
52e2f926f4 | ||
|
|
611fb279bc | ||
|
|
41b4e64be9 | ||
|
|
0d7315249d | ||
|
|
4913766d58 | ||
|
|
92da8c7044 | ||
|
|
9dba3d5385 | ||
|
|
2d3c26a4b2 | ||
|
|
8eba2d3e50 | ||
|
|
a8d4a27de1 | ||
|
|
c42167c6f4 | ||
|
|
44d182e2f9 | ||
|
|
ad95e35687 | ||
|
|
640a9995f4 | ||
|
|
95625f6871 | ||
|
|
2c20f72a9c | ||
|
|
5ad788e768 | ||
|
|
ed98c586c6 | ||
|
|
3e865708d6 | ||
|
|
c3bcbd63c0 | ||
|
|
29e29439ee | ||
|
|
0c19716f44 | ||
|
|
b24e1bafa1 | ||
|
|
64b899ac89 | ||
|
|
aa274e5ab7 | ||
|
|
7b3eaf3ccf | ||
|
|
1a5353d768 | ||
|
|
eff41759bc | ||
|
|
c23252ab53 | ||
|
|
1a3c57a031 | ||
|
|
4cc2c914e6 | ||
|
|
cbb46293ab | ||
|
|
765e00c949 | ||
|
|
662359908b | ||
|
|
0d99766686 | ||
|
|
ae3bc3358b | ||
|
|
1e0b4532bd | ||
|
|
4f8b19c686 | ||
|
|
84ab223b81 | ||
|
|
2bb21262d4 | ||
|
|
3188a9ffe6 | ||
|
|
61569a8610 | ||
|
|
075a84427f | ||
|
|
950f2759bd | ||
|
|
25c82ddf02 | ||
|
|
2d98df6122 | ||
|
|
219a5453f9 | ||
|
|
214a6a1386 | ||
|
|
e7781dc79c | ||
|
|
10c4bd1ac8 | ||
|
|
a42e488e58 | ||
|
|
06eb89b05b | ||
|
|
91c58ec027 | ||
|
|
8b26e42a3a | ||
|
|
acca011f15 | ||
|
|
2f59abdda7 | ||
|
|
17747a5c88 | ||
|
|
cec63546ff | ||
|
|
75f67d2de4 | ||
|
|
712385ffd5 | ||
|
|
ad90cf09fe | ||
|
|
f9928c9e25 | ||
|
|
a0741d99b8 | ||
|
|
c63f08c811 | ||
|
|
58b6c4d277 | ||
|
|
27c02549c8 | ||
|
|
88d371c71c | ||
|
|
b339524613 | ||
|
|
d5feda5c8a | ||
|
|
2f506425c2 | ||
|
|
e8167ee3d7 | ||
|
|
2f5e211065 | ||
|
|
39f4fb3446 | ||
|
|
56159b9bce | ||
|
|
b2af76e7dc | ||
|
|
491fe35397 | ||
|
|
b451285af7 | ||
|
|
63a1847cdc | ||
|
|
4e50fd8649 | ||
|
|
dfdffa0027 | ||
|
|
ebd2073144 | ||
|
|
1e94b716fb | ||
|
|
2a41abb3d1 | ||
|
|
2d2bebe976 | ||
|
|
e1629994bd | ||
|
|
e3d8fe4fd8 | ||
|
|
23d8742f0d | ||
|
|
3b6a8be07b | ||
|
|
71a5b72aff | ||
|
|
213bf349c3 | ||
|
|
a94fe55886 | ||
|
|
9b22f16497 | ||
|
|
2977a5957e | ||
|
|
f70d1c897a | ||
|
|
a4a3525265 | ||
|
|
a6dd8446e4 | ||
|
|
7bf9e1cfb3 | ||
|
|
f291832a77 | ||
|
|
1fee323247 | ||
|
|
a41accd033 | ||
|
|
37f7caf7f3 | ||
|
|
5847f7758c | ||
|
|
bce736993e | ||
|
|
5636992446 | ||
|
|
f996a2b7ae | ||
|
|
587de96ab3 | ||
|
|
80eb1cd202 | ||
|
|
bbf594c815 | ||
|
|
2f0f2ee40d | ||
|
|
96022d3aaf | ||
|
|
8eb5e3cbf8 | ||
|
|
ddc2625934 | ||
|
|
7f7ca697a0 | ||
|
|
900375679b | ||
|
|
9440b9e313 | ||
|
|
393f9e998b | ||
|
|
ba0bfe70a8 | ||
|
|
3c4a3e3f75 | ||
|
|
274fb09ed4 | ||
|
|
d44598a900 | ||
|
|
c9cfa59f54 | ||
|
|
7062234331 | ||
|
|
9754569525 | ||
|
|
52a071e34d | ||
|
|
2d8f749e36 | ||
|
|
a18cb74f03 | ||
|
|
6c442e239d | ||
|
|
eaf92fca4d | ||
|
|
06b7bad714 | ||
|
|
19eec2ed03 | ||
|
|
d99c54343a | ||
|
|
308a110000 | ||
|
|
4f406b2ce6 | ||
|
|
e564c555d7 | ||
|
|
f7ec9af9e8 | ||
|
|
4d93a774ce | ||
|
|
2595dd30bf | ||
|
|
9190365289 | ||
|
|
57794b3b9f | ||
|
|
3c36f651be | ||
|
|
8e6ddadba2 | ||
|
|
8a87a71927 | ||
|
|
0047e6f523 | ||
|
|
7183095a28 | ||
|
|
13c90893c7 | ||
|
|
976fbcd07f | ||
|
|
d97b077e85 | ||
|
|
8950575bfb | ||
|
|
11fc4c286f | ||
|
|
8d08e348a9 | ||
|
|
a18807f19e | ||
|
|
29f658fd3c | ||
|
|
a30bb8fed0 | ||
|
|
092ca1cd67 | ||
|
|
0df2539641 | ||
|
|
0f2d8a599c | ||
|
|
54b3143a1d | ||
|
|
148f7d2a91 | ||
|
|
1aa662f763 | ||
|
|
0b86b88de7 | ||
|
|
98033b1ba7 | ||
|
|
2b7eab629d | ||
|
|
0e4973e15c | ||
|
|
af0acf0dae | ||
|
|
76e5fe5a87 | ||
|
|
802c80f40c | ||
|
|
a51c5bd905 | ||
|
|
8c68556f52 | ||
|
|
cca1ea2404 | ||
|
|
281016a501 | ||
|
|
d4acdf2f89 | ||
|
|
0951e75c85 | ||
|
|
6b017b226a | ||
|
|
9e3bd7398c | ||
|
|
79f764c7a8 | ||
|
|
b5dc4353fb | ||
|
|
2fbac73c29 | ||
|
|
6616d105d1 | ||
|
|
6b4b19194e | ||
|
|
9785edd263 | ||
|
|
2a0bc11b68 | ||
|
|
dd0325a88d | ||
|
|
20783c0978 | ||
|
|
3f06a40bd5 | ||
|
|
68f43985ad | ||
|
|
915ca8f817 | ||
|
|
a65a81610b | ||
|
|
8eb6ed5639 | ||
|
|
795a8705c3 | ||
|
|
3af0dc3b3a | ||
|
|
9cf9b958a3 | ||
|
|
3ac2ba8d5a | ||
|
|
d893421c7b | ||
|
|
250b3bb579 | ||
|
|
e9edbfc051 | ||
|
|
e343db6f72 | ||
|
|
4d57d66f85 | ||
|
|
54ed6320c2 | ||
|
|
23083f3ae0 | ||
|
|
1985873494 | ||
|
|
8ae5917659 | ||
|
|
c91bfd08d8 | ||
|
|
49110a5872 | ||
|
|
c01c8edeb8 | ||
|
|
ff8cf067b8 | ||
|
|
1420f68050 | ||
|
|
c0be3e585a | ||
|
|
3049ef9151 | ||
|
|
4be00bbe6b | ||
|
|
9382dde098 | ||
|
|
1bf46b7711 | ||
|
|
b85bae31d9 | ||
|
|
0898829313 | ||
|
|
f8ad877601 | ||
|
|
585d1556c1 | ||
|
|
7486555875 | ||
|
|
fc30b1bacc | ||
|
|
0dd19af6e8 | ||
|
|
4c44515f9d | ||
|
|
9d84d6dd64 | ||
|
|
0f708daf2d | ||
|
|
b9354de8fd | ||
|
|
c9d5f4c898 | ||
|
|
810c150781 | ||
|
|
31dd538c0b | ||
|
|
62e38e7c45 | ||
|
|
b9da28a29b | ||
|
|
84bfa8a6b1 | ||
|
|
1f830963f6 | ||
|
|
12d2c6fe89 | ||
|
|
f43faf15f8 | ||
|
|
173a38a374 | ||
|
|
1604ff15b5 | ||
|
|
214fe502cd | ||
|
|
aae45a8179 | ||
|
|
075ca9ca47 | ||
|
|
d4253d7a55 | ||
|
|
0917dc8766 | ||
|
|
aba86855b5 | ||
|
|
ed5386c213 | ||
|
|
455e75e92f | ||
|
|
c394de0c88 | ||
|
|
bad1990173 | ||
|
|
0bc159341d | ||
|
|
45bf1fd63a | ||
|
|
ff0de85817 | ||
|
|
727fa9f929 | ||
|
|
0b9bc18236 | ||
|
|
bad3b83d33 | ||
|
|
00967a98ac | ||
|
|
1d708ab351 | ||
|
|
ba6759010b | ||
|
|
da3868c104 | ||
|
|
0abf4d5d5d | ||
|
|
9b320cd43f | ||
|
|
28783a4146 | ||
|
|
f92927eae5 | ||
|
|
294139ce7a | ||
|
|
45becd2573 | ||
|
|
a3bee01e0a | ||
|
|
1dc93ec4f0 | ||
|
|
3562d4220c | ||
|
|
1532f6e427 | ||
|
|
9327810bbf | ||
|
|
f66d5f1e58 | ||
|
|
cec086994e | ||
|
|
942d8f1ced | ||
|
|
1552dcb143 | ||
|
|
d525f1c9e4 | ||
|
|
921f2dfcdf | ||
|
|
79a006c8de | ||
|
|
ff27746c0c | ||
|
|
87788f354f | ||
|
|
7d2e440c83 | ||
|
|
5551f9d56f | ||
|
|
1fb91c6316 | ||
|
|
e60949ff3f | ||
|
|
278a3c6890 | ||
|
|
fcf734eb36 | ||
|
|
cf3cddafab | ||
|
|
c52664f22e | ||
|
|
cb712ff37d | ||
|
|
f4ae610448 | ||
|
|
601b8bc98d | ||
|
|
80b4cec87a | ||
|
|
76c7b69e4e | ||
|
|
a5bd3c4dda | ||
|
|
f06e9b5605 | ||
|
|
7a3bb0e55c | ||
|
|
6e8f535e8b | ||
|
|
5619a75b05 | ||
|
|
53dfb78215 | ||
|
|
8e97cbab1e | ||
|
|
ce7b749fd5 | ||
|
|
6617bd6609 | ||
|
|
e610fb3201 | ||
|
|
40f1d35415 | ||
|
|
b79bf7d414 | ||
|
|
3724cc3a15 | ||
|
|
3418e8c9af | ||
|
|
9619dff334 | ||
|
|
1b2feb19e5 | ||
|
|
1829dc3d9f | ||
|
|
bd0e81f5a0 | ||
|
|
f04d360ee2 | ||
|
|
92f27281fa | ||
|
|
65781b9316 | ||
|
|
9be0be0316 | ||
|
|
9f5f004725 | ||
|
|
fed77cccf3 | ||
|
|
9b520dfb78 | ||
|
|
8ad2be10b2 | ||
|
|
2d277a15f5 | ||
|
|
d60468bb05 | ||
|
|
82d6210464 | ||
|
|
ff198042d2 | ||
|
|
6b47e29583 | ||
|
|
380c38674c | ||
|
|
3c14a0891e | ||
|
|
8513a07416 | ||
|
|
220485a849 | ||
|
|
4db34b0506 | ||
|
|
5677c912a8 | ||
|
|
7a24de15e4 | ||
|
|
99d9ea283a | ||
|
|
dac92a0e0a | ||
|
|
a25efb16f3 | ||
|
|
e4d73b29a1 | ||
|
|
8a875f292e | ||
|
|
60a85621ea | ||
|
|
115d20373c | ||
|
|
cdf33e5748 | ||
|
|
01d0a9f412 | ||
|
|
8cc2d3b4fe | ||
|
|
aba9e4f3be | ||
|
|
4d575ba13a | ||
|
|
7f0e4ad448 | ||
|
|
17cc14a9d2 | ||
|
|
1f8016182c | ||
|
|
caf9ef2c4b | ||
|
|
64b57f2da3 | ||
|
|
efd2c99862 | ||
|
|
cc05ba8907 | ||
|
|
16763b715a | ||
|
|
ffaa598796 | ||
|
|
858e16d34f | ||
|
|
a60e62efb1 | ||
|
|
97f9d4be67 | ||
|
|
fa4eec41f7 | ||
|
|
77516c97db | ||
|
|
cba01f0865 | ||
|
|
8b754017ca | ||
|
|
a27600046e | ||
|
|
fb2667631d | ||
|
|
b638f7037a | ||
|
|
74699a8262 | ||
|
|
eabf2a4582 | ||
|
|
325d62b41c | ||
|
|
e955a056e2 | ||
|
|
723f8c5fd5 | ||
|
|
a16137f53f | ||
|
|
d60b8b97f9 | ||
|
|
7b0bc51183 | ||
|
|
53aa076555 | ||
|
|
f57370f33a | ||
|
|
c557d51b6f | ||
|
|
df3fdc26a0 | ||
|
|
af00c34aac | ||
|
|
120bf39f55 | ||
|
|
26a7e35f27 | ||
|
|
d44d2a5f00 | ||
|
|
7f1d86b338 | ||
|
|
d8816280f0 | ||
|
|
b09a73040f | ||
|
|
740b5f2602 | ||
|
|
96841c70c7 | ||
|
|
f92735d35d | ||
|
|
516fd3c92d | ||
|
|
a775b57134 | ||
|
|
bf21604d42 | ||
|
|
1bb39eba87 | ||
|
|
3190703dc8 | ||
|
|
5095db8a43 | ||
|
|
1f1634ea59 | ||
|
|
a7dd033c3b | ||
|
|
95e78ffa05 | ||
|
|
42276ea7d0 | ||
|
|
dffd67eb31 | ||
|
|
55e79063d6 | ||
|
|
46f4bbb3b5 | ||
|
|
240559581a | ||
|
|
48ba829465 | ||
|
|
eef654de98 | ||
|
|
d76a04bd0a | ||
|
|
a8fe54a78d | ||
|
|
0bcb0b882f | ||
|
|
4525fa31aa | ||
|
|
aeaea0574f | ||
|
|
99d71c2177 | ||
|
|
3e60cfafd3 | ||
|
|
3123695869 | ||
|
|
777af73e2b | ||
|
|
716751cf76 | ||
|
|
6ebd5cbbd8 | ||
|
|
077b818d82 | ||
|
|
5af1d80055 | ||
|
|
f236d12166 | ||
|
|
127eb908f3 | ||
|
|
40d76b2296 | ||
|
|
8147815037 | ||
|
|
57f156be83 | ||
|
|
2cfd880cdb | ||
|
|
430b38e770 | ||
|
|
e7f463a082 | ||
|
|
1d39c771e4 | ||
|
|
c81c0dd22a | ||
|
|
f8a1ab4622 | ||
|
|
5d3309fdcd | ||
|
|
4ae028fe73 | ||
|
|
707db950c8 | ||
|
|
94812d8648 | ||
|
|
8548b69e6e | ||
|
|
e3cb665d92 | ||
|
|
fb713ed91b | ||
|
|
d99eacc2e1 | ||
|
|
62e55214fc | ||
|
|
464d27ad7e | ||
|
|
f88c5f6c08 | ||
|
|
da6ce791bc | ||
|
|
b33b50987b | ||
|
|
5193634a52 | ||
|
|
0d94746f4a | ||
|
|
85680935d4 | ||
|
|
46e2683995 | ||
|
|
492722af8b | ||
|
|
56749dfb20 | ||
|
|
04567c765e | ||
|
|
048158ad6d | ||
|
|
7326b9e10d | ||
|
|
8522d8f29c | ||
|
|
bab385c342 | ||
|
|
bb27ef7939 | ||
|
|
d2044c647b | ||
|
|
c585d00f16 | ||
|
|
015c076315 | ||
|
|
426aa33723 | ||
|
|
da8e415ae1 | ||
|
|
1b834c6858 | ||
|
|
d82726cd1b | ||
|
|
288f0a06bb | ||
|
|
0121d75032 | ||
|
|
53c86702a3 | ||
|
|
192fe89789 | ||
|
|
959ca3cef3 | ||
|
|
ccd55d2a28 | ||
|
|
bfa9a83d31 | ||
|
|
2f7b4d7f68 | ||
|
|
d887855e16 | ||
|
|
1a1e68ec98 | ||
|
|
a2754f15fc | ||
|
|
b6d81f34ba | ||
|
|
f9fb33e696 | ||
|
|
f72d5de2d7 | ||
|
|
0365c0786a | ||
|
|
af7a00d030 | ||
|
|
8a7efce941 | ||
|
|
ce73aa5a74 | ||
|
|
64d63a25cc | ||
|
|
2cef9c4fcf | ||
|
|
4265d43096 | ||
|
|
25857591a2 | ||
|
|
27f5a1a685 | ||
|
|
84da2d6a29 | ||
|
|
859ebad55d | ||
|
|
e538a4d304 | ||
|
|
f94c2b40a3 | ||
|
|
47d29ecf63 | ||
|
|
f2088a687e | ||
|
|
faeeee2948 | ||
|
|
7923cfe8f8 | ||
|
|
b51d0a9b05 | ||
|
|
09f22a801e | ||
|
|
3a824c5f9d | ||
|
|
df02f51c56 | ||
|
|
fc5e3a6728 | ||
|
|
57fbd3c723 | ||
|
|
25cd1e2cc1 | ||
|
|
f5659d455d | ||
|
|
5ed7abdbeb | ||
|
|
09875fe160 | ||
|
|
f716b8fc0f | ||
|
|
9f66f93641 | ||
|
|
f00d4d7d3f | ||
|
|
0929535b2e | ||
|
|
8869e253ca | ||
|
|
f3a5ea2956 | ||
|
|
f4d4dc91b1 | ||
|
|
c6fd65d1d7 | ||
|
|
0795906533 | ||
|
|
a2b45bc799 | ||
|
|
757657f29c | ||
|
|
219c7659e1 | ||
|
|
ae32bae791 | ||
|
|
57eba77561 | ||
|
|
d5bc7c4343 | ||
|
|
32f57b7c26 | ||
|
|
692bb8faa7 | ||
|
|
455a0fc239 | ||
|
|
b2cbd13251 | ||
|
|
ce21ba1545 | ||
|
|
c89085bf44 | ||
|
|
4254ed3c63 | ||
|
|
85564a35fd | ||
|
|
09713d40ba | ||
|
|
16d5aeac7c | ||
|
|
e19ba5a06a | ||
|
|
f7a5077d5d | ||
|
|
f8dc24bc09 | ||
|
|
e9419f10d3 | ||
|
|
cded603c27 | ||
|
|
d2ae3ebf9e | ||
|
|
730ccdd456 | ||
|
|
2f042ad915 | ||
|
|
ba70691877 | ||
|
|
ed11686a99 | ||
|
|
5c50d86908 | ||
|
|
fea31753b0 | ||
|
|
0d64cd8bab | ||
|
|
9be0f8f000 | ||
|
|
78401214b0 | ||
|
|
b2a07aba3a | ||
|
|
1e0bb3da95 | ||
|
|
59994da176 | ||
|
|
3d281b3316 | ||
|
|
ea86849a58 | ||
|
|
399789811e | ||
|
|
8d117cb0a4 | ||
|
|
588b8e0303 | ||
|
|
1794922263 | ||
|
|
0ededb8863 | ||
|
|
aa59bb1a41 | ||
|
|
f2703979a4 | ||
|
|
d2a1dc792f | ||
|
|
06d66a0b2b | ||
|
|
0e2522279e | ||
|
|
141a42a75b | ||
|
|
a1bf37e457 | ||
|
|
a20b7895a9 | ||
|
|
5666821e7b | ||
|
|
5132d8f097 | ||
|
|
b81ff9c008 | ||
|
|
7e62bc4819 | ||
|
|
d058be25ad | ||
|
|
1269be1d04 | ||
|
|
3b8837a16b | ||
|
|
32f478e4a0 | ||
|
|
e2b50d6194 | ||
|
|
74e33b0a51 | ||
|
|
107969c09a | ||
|
|
d379118772 | ||
|
|
291594b99c | ||
|
|
f2cdda7278 | ||
|
|
6911458d15 | ||
|
|
6238effdc2 | ||
|
|
498377a230 | ||
|
|
3dd4ec57ff | ||
|
|
e15b0e04b8 | ||
|
|
97b1fc813b | ||
|
|
917040b044 | ||
|
|
69646a160d | ||
|
|
54adb0509e | ||
|
|
bd3a3b6eaf | ||
|
|
296428d53e | ||
|
|
e0ca876de2 | ||
|
|
a431a4fa04 | ||
|
|
cc2bd03ec9 | ||
|
|
1fe81b7d1e | ||
|
|
0bd5a0d92d | ||
|
|
330ddb6a30 | ||
|
|
52dbd702ad | ||
|
|
d7c3570ba3 | ||
|
|
ab4d51b40b | ||
|
|
1665c93d3b | ||
|
|
b51fdbce9f | ||
|
|
351b423e15 | ||
|
|
7690be1647 | ||
|
|
68aeb93afa | ||
|
|
51062863a5 | ||
|
|
4fb4b7aa6c | ||
|
|
7f3cbcedc0 | ||
|
|
6ef09def81 | ||
|
|
c4c6aff9a5 | ||
|
|
d71850cef6 | ||
|
|
2597c9bfac | ||
|
|
93307b57aa | ||
|
|
618953c865 | ||
|
|
e04dd78624 | ||
|
|
fa0c4025f7 | ||
|
|
2d2d185200 | ||
|
|
cb7278eb50 | ||
|
|
89aa114192 | ||
|
|
ed062e0ce5 | ||
|
|
a69ef8402b | ||
|
|
8779f67d2d | ||
|
|
e4b72136b8 | ||
|
|
4ff5091bc2 | ||
|
|
6f131250f1 | ||
|
|
221a63d980 | ||
|
|
d02eda147c | ||
|
|
9b25716136 | ||
|
|
6628a47f23 | ||
|
|
ec0e6bc3f8 | ||
|
|
d2c02be3a0 | ||
|
|
594492fbdd | ||
|
|
bd9ea7a88d | ||
|
|
51327a4056 | ||
|
|
33bd60528b | ||
|
|
7e54474111 | ||
|
|
e307069d62 | ||
|
|
91db63294c | ||
|
|
fd04e08c9c | ||
|
|
6576409d60 | ||
|
|
045cb2058c | ||
|
|
d03afc12fd | ||
|
|
48799a3cff | ||
|
|
dba259e9f1 | ||
|
|
07885f5810 | ||
|
|
696c518550 | ||
|
|
411ef2691c | ||
|
|
fc6074ea9f | ||
|
|
ab1670e2ce | ||
|
|
9142a33bbf | ||
|
|
f6eefa4ecc | ||
|
|
f1db166ac4 | ||
|
|
887c2bc56d | ||
|
|
f0738a93c3 | ||
|
|
75381c2c6e | ||
|
|
bf0b9959d1 | ||
|
|
406a54b597 | ||
|
|
be04d1a862 | ||
|
|
85b2d5a124 | ||
|
|
521a7ed7b0 | ||
|
|
529b188164 | ||
|
|
8d307d8134 | ||
|
|
8c675b52bc | ||
|
|
aa51aa2aa0 | ||
|
|
86865c6da5 | ||
|
|
45296100df | ||
|
|
1605fbc012 | ||
|
|
c6c92e273d | ||
|
|
467b373c43 | ||
|
|
72ce7f06e9 | ||
|
|
346a7284f7 | ||
|
|
ee4ac67081 | ||
|
|
5a93d14d75 | ||
|
|
96a47a60ad | ||
|
|
b24a47ad7f | ||
|
|
cd1fd1bb7c | ||
|
|
d44df7b6e6 | ||
|
|
9d1ac0c84b | ||
|
|
76af9cba5a | ||
|
|
b69fc30902 | ||
|
|
c3174f4de9 | ||
|
|
99ce68e9ba | ||
|
|
0cf73673a9 | ||
|
|
08f442dc7b | ||
|
|
8a8b95228c | ||
|
|
31a752fa21 | ||
|
|
a83831e68d | ||
|
|
a12a8d4fe2 | ||
|
|
e57f3a7e6c | ||
|
|
68fbed9281 | ||
|
|
8bfaa007d5 | ||
|
|
76360f89c1 | ||
|
|
d525230abd | ||
|
|
b4aa637d41 | ||
|
|
7c4334d0de | ||
|
|
062be8d7c9 | ||
|
|
db25ee59c5 | ||
|
|
4b0bc6d0bf | ||
|
|
8c0b04b995 | ||
|
|
e5989adf92 | ||
|
|
9e5da2f9d7 | ||
|
|
a284a228a3 | ||
|
|
2133e0d1be | ||
|
|
a6f37f1d61 | ||
|
|
9de9151826 | ||
|
|
fdd5ada98c | ||
|
|
80fcf18e24 | ||
|
|
ab94b5ca7a | ||
|
|
8d2ce56c37 | ||
|
|
1ec324354b | ||
|
|
16be6601c8 | ||
|
|
98027446c8 | ||
|
|
f2f1d874e1 | ||
|
|
25a72113b1 | ||
|
|
79c4ad5015 | ||
|
|
e24f1c7c87 | ||
|
|
dbf8a326d5 | ||
|
|
0bc9c70c66 | ||
|
|
594d2155e3 | ||
|
|
20dbd71306 | ||
|
|
6a727b9723 | ||
|
|
02a5bc096f | ||
|
|
2110db6f0c | ||
|
|
2bac867382 | ||
|
|
5fbd8a3be0 | ||
|
|
ad6440b603 | ||
|
|
064b6a915f | ||
|
|
1578ebb0e2 | ||
|
|
73525a4bbc | ||
|
|
d62f49d1fc | ||
|
|
63b88e77f2 | ||
|
|
3d8f15c20b | ||
|
|
cac5d56d60 | ||
|
|
bd2a672c14 | ||
|
|
82396e73f5 | ||
|
|
ba928b169d | ||
|
|
4fed720f97 | ||
|
|
78238c85d4 | ||
|
|
4f2ae7b73f | ||
|
|
f82a9cc7ac | ||
|
|
cce7624ab8 | ||
|
|
c5ecd09172 | ||
|
|
7b21c1c2f4 | ||
|
|
f8714d81f5 | ||
|
|
8622656005 | ||
|
|
52237fadb6 | ||
|
|
222cccf388 | ||
|
|
bab308508e | ||
|
|
dedb83c867 | ||
|
|
723a90cdd6 | ||
|
|
67d2398fa8 | ||
|
|
5f3b6ec007 | ||
|
|
55ab0c12f1 | ||
|
|
d1227b5fc9 | ||
|
|
6ea368c383 | ||
|
|
e92b6de09f | ||
|
|
e622587db4 | ||
|
|
f2efc06d1f | ||
|
|
a2b94452db | ||
|
|
4c506f7cc3 | ||
|
|
7886f05e88 | ||
|
|
f58be0d1c1 | ||
|
|
1152394bc1 | ||
|
|
a082b5a590 | ||
|
|
bae9484df2 | ||
|
|
6f78485878 | ||
|
|
fd0fe3390b | ||
|
|
2522158127 | ||
|
|
8be107cecc | ||
|
|
5aab158c0b | ||
|
|
1d33e60e36 | ||
|
|
83c28cb857 | ||
|
|
df5bce27b0 | ||
|
|
2b15739b48 | ||
|
|
3480c88e90 | ||
|
|
432cd0f99d | ||
|
|
e8b3e9b22d | ||
|
|
d4a47671ea | ||
|
|
0bcd1e62f3 | ||
|
|
80822b7fff | ||
|
|
78f1011f52 | ||
|
|
67f6257617 | ||
|
|
169c614489 | ||
|
|
da908c438a | ||
|
|
9c9c4bf1f9 | ||
|
|
7764493298 | ||
|
|
64a20ee61b | ||
|
|
62d1af8c37 | ||
|
|
0f5274fdf6 | ||
|
|
2e2187ebf4 | ||
|
|
762c3350f4 | ||
|
|
e1a4d7f77e | ||
|
|
a7a4554a85 | ||
|
|
6bd808ce91 | ||
|
|
a5c143bc46 | ||
|
|
87c9cac756 | ||
|
|
6a047f8722 | ||
|
|
6523494e83 | ||
|
|
7c6ce8bb90 | ||
|
|
dafbfe4021 | ||
|
|
a4d5c94d9b | ||
|
|
7119e378a7 | ||
|
|
e1dc3032c1 | ||
|
|
5de03b8921 | ||
|
|
7631d43c48 | ||
|
|
d0b2ee5c85 | ||
|
|
8830a5a1df | ||
|
|
ee87626a93 | ||
|
|
9f15d38c1c | ||
|
|
4a96a977c0 | ||
|
|
9a95293bdf | ||
|
|
0b3a06d263 | ||
|
|
9a6249c4f5 | ||
|
|
50bd51e461 | ||
|
|
04f8013314 | ||
|
|
a0aaf0057a | ||
|
|
8e78b3e6be | ||
|
|
57a503818d | ||
|
|
25d2ff3e9b | ||
|
|
31902d3e57 | ||
|
|
16f3fa6bae | ||
|
|
1f706673cf | ||
|
|
fac5f69ad2 | ||
|
|
97c944bb63 | ||
|
|
d0c4fe78ee | ||
|
|
265457b451 | ||
|
|
4a4a29c9f6 | ||
|
|
0a91b9e1c9 | ||
|
|
f56163295c | ||
|
|
d1c87c068b | ||
|
|
fa20761110 | ||
|
|
e4a0e0a0e9 | ||
|
|
d30ae19e2a | ||
|
|
5c919e6bff | ||
|
|
434393d1c3 | ||
|
|
af9aa5d7cb | ||
|
|
05eb75442a | ||
|
|
3496ed0c7e | ||
|
|
1b89604c7a | ||
|
|
67a9d133e9 | ||
|
|
ed9118b346 | ||
|
|
59e55cfbd5 | ||
|
|
788d3b32ac | ||
|
|
1d414cf2fd | ||
|
|
cc3c168162 | ||
|
|
1ee6837f0e | ||
|
|
27dcea7c5b | ||
|
|
dcda7f7b8c | ||
|
|
e0cbb69a4f | ||
|
|
7ec95f786d | ||
|
|
1efe40add5 | ||
|
|
cbd73ee313 | ||
|
|
34227a7a39 | ||
|
|
71cb9b2d1d | ||
|
|
cd4c9b194f | ||
|
|
98762a0235 | ||
|
|
2fd1fd9573 | ||
|
|
aff3964078 | ||
|
|
2778580397 | ||
|
|
962062fe44 | ||
|
|
0578b21270 | ||
|
|
36a800c3f5 | ||
|
|
6d21f84187 | ||
|
|
f1e9833310 | ||
|
|
46f5acc4f9 | ||
|
|
95d4dcaeb3 | ||
|
|
64c542e614 | ||
|
|
13d081ea80 | ||
|
|
c0f9d86287 | ||
|
|
bcdecdaa73 | ||
|
|
daac3ebca2 | ||
|
|
639f9cf966 | ||
|
|
4fc48b5aa4 | ||
|
|
307ff77b42 | ||
|
|
9b500bc5f7 | ||
|
|
e313154134 | ||
|
|
27e94c438d | ||
|
|
58392876df | ||
|
|
115c4b1aa7 | ||
|
|
ba5649d259 | ||
|
|
1b30575510 | ||
|
|
7dbebd3ea7 | ||
|
|
6f18790352 | ||
|
|
d1e04a2ece | ||
|
|
bea0bbd0c2 | ||
|
|
0530503ef2 | ||
|
|
d1e8ff814b | ||
|
|
4f8ae761a2 | ||
|
|
b530e92834 | ||
|
|
b2a6777995 | ||
|
|
b461fc5e40 | ||
|
|
b7a8c6b60f | ||
|
|
41aa8ad799 | ||
|
|
7973baedd0 | ||
|
|
299b71d982 | ||
|
|
76aafe1fa8 | ||
|
|
95a0229aaf | ||
|
|
915a8fbad7 |
2
.dockerignore
Normal file
2
.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
||||
Dockerfile
|
||||
tgs.Dockerfile
|
||||
3
.fixmie.yml
Normal file
3
.fixmie.yml
Normal file
@@ -0,0 +1,3 @@
|
||||
go:
|
||||
comments:
|
||||
disabled: true
|
||||
28
.github/ISSUE_TEMPLATE.md
vendored
28
.github/ISSUE_TEMPLATE.md
vendored
@@ -1,22 +1,36 @@
|
||||
If you have a configuration problem, please first try to create a basic configuration following the instructions on [the wiki](https://github.com/42wim/matterbridge/wiki/How-to-create-your-config) before filing an issue.
|
||||
<!-- This is a bug report template. By following the instructions below and
|
||||
filling out the sections with your information, you will help the us to get all
|
||||
the necessary data to fix your issue.
|
||||
|
||||
Please answer the following questions.
|
||||
You can also preview your report before submitting it.
|
||||
|
||||
### Which version of matterbridge are you using?
|
||||
run ```matterbridge -version```
|
||||
Text between <!-- and --> marks will be invisible in the report.
|
||||
-->
|
||||
|
||||
### If you're having problems with mattermost please specify mattermost version.
|
||||
<!-- If you have a configuration problem, please first try to create a basic configuration following the instructions on [the wiki](https://github.com/42wim/matterbridge/wiki/How-to-create-your-config) before filing an issue. -->
|
||||
|
||||
|
||||
### Environment
|
||||
<!-- run `matterbridge -version` -->
|
||||
<!-- If you're having problems with mattermost also specify the mattermost version. -->
|
||||
Version:
|
||||
|
||||
<!-- What operating system are you using ? (be as specific as possible) -->
|
||||
Operating system:
|
||||
|
||||
<!-- If you compiled matterbridge yourself:
|
||||
* Specify the output of `go version`
|
||||
* Specify the output of `git rev-parse HEAD` -->
|
||||
|
||||
### Please describe the expected behavior.
|
||||
|
||||
|
||||
### Please describe the actual behavior.
|
||||
#### Use logs from running ```matterbridge -debug``` if possible.
|
||||
<!-- Use logs from running `matterbridge -debug` if possible. -->
|
||||
|
||||
|
||||
### Any steps to reproduce the behavior?
|
||||
|
||||
|
||||
### Please add your configuration file
|
||||
#### (be sure to exclude or anonymize private data (tokens/passwords))
|
||||
<!-- (be sure to exclude or anonymize private data (tokens/passwords)) -->
|
||||
|
||||
27
.github/ISSUE_TEMPLATE/Bug_report.md
vendored
Normal file
27
.github/ISSUE_TEMPLATE/Bug_report.md
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve. (Check the FAQ on the wiki first)
|
||||
labels: bug
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots/debug logs**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
Use logs from running `matterbridge -debug` if possible.
|
||||
|
||||
**Environment (please complete the following information):**
|
||||
- OS: [e.g. linux]
|
||||
- Matterbridge version: output of `matterbridge -version`
|
||||
- If self compiled: output of `git rev-parse HEAD`
|
||||
|
||||
**Additional context**
|
||||
Please add your configuration file (be sure to exclude or anonymize private data (tokens/passwords))
|
||||
18
.github/ISSUE_TEMPLATE/Feature_request.md
vendored
Normal file
18
.github/ISSUE_TEMPLATE/Feature_request.md
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
labels: enhancement
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
71
.github/workflows/codeql-analysis.yml
vendored
Normal file
71
.github/workflows/codeql-analysis.yml
vendored
Normal file
@@ -0,0 +1,71 @@
|
||||
# For most projects, this workflow file will not need changing; you simply need
|
||||
# to commit it to your repository.
|
||||
#
|
||||
# You may wish to alter this file to override the set of languages analyzed,
|
||||
# or to provide custom queries or build logic.
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [master]
|
||||
schedule:
|
||||
- cron: '0 16 * * 1'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Override automatic language detection by changing the below list
|
||||
# Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python']
|
||||
language: ['go']
|
||||
# Learn more...
|
||||
# https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
# We must fetch at least the immediate parents so that if this is
|
||||
# a pull request then we can checkout the head.
|
||||
fetch-depth: 2
|
||||
|
||||
# If this run was triggered by a pull request event, then checkout
|
||||
# the head of the pull request instead of the merge commit.
|
||||
- run: git checkout HEAD^2
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v1
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v1
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
|
||||
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
|
||||
# and modify them (or add more) to build your code if your project
|
||||
# uses a compiled language
|
||||
|
||||
#- run: |
|
||||
# make bootstrap
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v1
|
||||
57
.github/workflows/development.yml
vendored
Normal file
57
.github/workflows/development.yml
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
name: Development
|
||||
on: [push, pull_request]
|
||||
jobs:
|
||||
lint:
|
||||
name: golangci-lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 20
|
||||
- name: Run golangci-lint
|
||||
uses: golangci/golangci-lint-action@v2
|
||||
with:
|
||||
version: v1.29
|
||||
args: "-v --new-from-rev HEAD~5"
|
||||
test-build-upload:
|
||||
strategy:
|
||||
matrix:
|
||||
go-version: [1.14.x, 1.15.x]
|
||||
platform: [ubuntu-latest]
|
||||
runs-on: ${{ matrix.platform }}
|
||||
steps:
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: ${{ matrix.go-version }}
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Test
|
||||
run: go test ./... -mod=vendor
|
||||
- name: Build
|
||||
run: |
|
||||
mkdir -p output/{win,lin,arm,mac}
|
||||
VERSION=$(git describe --tags)
|
||||
GOOS=linux GOARCH=amd64 go build -mod=vendor -ldflags "-s -X main.githash=$(git log --pretty=format:'%h' -n 1)" -o output/lin/matterbridge-$VERSION-linux-amd64
|
||||
GOOS=windows GOARCH=amd64 go build -mod=vendor -ldflags "-s -X main.githash=$(git log --pretty=format:'%h' -n 1)" -o output/win/matterbridge-$VERSION-windows-amd64.exe
|
||||
GOOS=darwin GOARCH=amd64 go build -mod=vendor -ldflags "-s -X main.githash=$(git log --pretty=format:'%h' -n 1)" -o output/mac/matterbridge-$VERSION-darwin-amd64
|
||||
- name: Upload linux 64-bit
|
||||
if: startsWith(matrix.go-version,'1.15')
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: matterbridge-linux-64bit
|
||||
path: output/lin
|
||||
- name: Upload windows 64-bit
|
||||
if: startsWith(matrix.go-version,'1.15')
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: matterbridge-windows-64bit
|
||||
path: output/win
|
||||
- name: Upload darwin 64-bit
|
||||
if: startsWith(matrix.go-version,'1.15')
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: matterbridge-darwin-64bit
|
||||
path: output/mac
|
||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
# Exclude matterbridge binary
|
||||
/matterbridge
|
||||
/matterbridge.exe
|
||||
|
||||
# Exclude configuration file
|
||||
matterbridge.toml
|
||||
215
.golangci.yaml
Normal file
215
.golangci.yaml
Normal file
@@ -0,0 +1,215 @@
|
||||
# For full documentation of the configuration options please
|
||||
# see: https://github.com/golangci/golangci-lint#config-file.
|
||||
|
||||
# options for analysis running
|
||||
run:
|
||||
# default concurrency is the available CPU number
|
||||
# concurrency: 4
|
||||
|
||||
# timeout for analysis, e.g. 30s, 5m, default is 1m
|
||||
deadline: 2m
|
||||
|
||||
# exit code when at least one issue was found, default is 1
|
||||
issues-exit-code: 1
|
||||
|
||||
# include test files or not, default is true
|
||||
tests: true
|
||||
|
||||
# list of build tags, all linters use it. Default is empty list.
|
||||
build-tags:
|
||||
|
||||
# which dirs to skip: they won't be analyzed;
|
||||
# can use regexp here: generated.*, regexp is applied on full path;
|
||||
# default value is empty list, but next dirs are always skipped independently
|
||||
# from this option's value:
|
||||
# vendor$, third_party$, testdata$, examples$, Godeps$, builtin$
|
||||
skip-dirs: gateway/bridgemap$
|
||||
|
||||
# which files to skip: they will be analyzed, but issues from them
|
||||
# won't be reported. Default value is empty list, but there is
|
||||
# no need to include all autogenerated files, we confidently recognize
|
||||
# autogenerated files. If it's not please let us know.
|
||||
skip-files:
|
||||
|
||||
|
||||
# output configuration options
|
||||
output:
|
||||
# colored-line-number|line-number|json|tab|checkstyle, default is "colored-line-number"
|
||||
format: colored-line-number
|
||||
|
||||
# print lines of code with issue, default is true
|
||||
print-issued-lines: true
|
||||
|
||||
# print linter name in the end of issue text, default is true
|
||||
print-linter-name: true
|
||||
|
||||
|
||||
# all available settings of specific linters, we can set an option for
|
||||
# a given linter even if we deactivate that same linter at runtime
|
||||
linters-settings:
|
||||
errcheck:
|
||||
# report about not checking of errors in type assertions: `a := b.(MyStruct)`;
|
||||
# default is false: such cases aren't reported by default.
|
||||
check-type-assertions: false
|
||||
|
||||
# report about assignment of errors to blank identifier: `num, _ := strconv.Atoi(numStr)`;
|
||||
# default is false: such cases aren't reported by default.
|
||||
check-blank: false
|
||||
govet:
|
||||
# report about shadowed variables
|
||||
check-shadowing: true
|
||||
golint:
|
||||
# minimal confidence for issues, default is 0.8
|
||||
min-confidence: 0.8
|
||||
gofmt:
|
||||
# simplify code: gofmt with `-s` option, true by default
|
||||
simplify: true
|
||||
goimports:
|
||||
# put imports beginning with prefix after 3rd-party packages;
|
||||
# it's a comma-separated list of prefixes
|
||||
local-prefixes: github.com
|
||||
gocyclo:
|
||||
# minimal code complexity to report, 30 by default (but we recommend 10-20)
|
||||
min-complexity: 15
|
||||
maligned:
|
||||
# print struct with more effective memory layout or not, false by default
|
||||
suggest-new: true
|
||||
dupl:
|
||||
# tokens count to trigger issue, 150 by default
|
||||
threshold: 150
|
||||
goconst:
|
||||
# minimal length of string constant, 3 by default
|
||||
min-len: 3
|
||||
# minimal occurrences count to trigger, 3 by default
|
||||
min-occurrences: 3
|
||||
depguard:
|
||||
list-type: blacklist
|
||||
include-go-root: false
|
||||
packages:
|
||||
# List of packages that we would want to blacklist for... reasons.
|
||||
misspell:
|
||||
# Correct spellings using locale preferences for US or UK.
|
||||
# Default is to use a neutral variety of English.
|
||||
# Setting locale to US will correct the British spelling of 'colour' to 'color'.
|
||||
lll:
|
||||
# max line length, lines longer will be reported. Default is 120.
|
||||
# '\t' is counted as 1 character by default, and can be changed with the tab-width option
|
||||
line-length: 150
|
||||
# tab width in spaces. Default to 1.
|
||||
tab-width: 1
|
||||
unused:
|
||||
# treat code as a program (not a library) and report unused exported identifiers; default is false.
|
||||
# XXX: if you enable this setting, unused will report a lot of false-positives in text editors:
|
||||
# if it's called for subdir of a project it can't find funcs usages. All text editor integrations
|
||||
# with golangci-lint call it on a directory with the changed file.
|
||||
check-exported: false
|
||||
unparam:
|
||||
# Inspect exported functions, default is false. Set to true if no external program/library imports your code.
|
||||
# XXX: if you enable this setting, unparam will report a lot of false-positives in text editors:
|
||||
# if it's called for subdir of a project it can't find external interfaces. All text editor integrations
|
||||
# with golangci-lint call it on a directory with the changed file.
|
||||
check-exported: false
|
||||
nakedret:
|
||||
# make an issue if func has more lines of code than this setting and it has naked returns; default is 30
|
||||
max-func-lines: 0 # Warn on all naked returns.
|
||||
prealloc:
|
||||
# XXX: we don't recommend using this linter before doing performance profiling.
|
||||
# For most programs usage of prealloc will be a premature optimization.
|
||||
|
||||
# Report preallocation suggestions only on simple loops that have no returns/breaks/continues/gotos in them.
|
||||
# True by default.
|
||||
simple: true
|
||||
range-loops: true # Report preallocation suggestions on range loops, true by default
|
||||
for-loops: false # Report preallocation suggestions on for loops, false by default
|
||||
gocritic:
|
||||
# which checks should be enabled; can't be combined with 'disabled-checks';
|
||||
# default are: [appendAssign assignOp caseOrder dupArg dupBranchBody dupCase flagDeref
|
||||
# ifElseChain regexpMust singleCaseSwitch sloppyLen switchTrue typeSwitchVar underef
|
||||
# unlambda unslice rangeValCopy defaultCaseOrder];
|
||||
# all checks list: https://github.com/go-critic/checkers
|
||||
# disabled for now - hugeParam
|
||||
enabled-checks:
|
||||
- appendAssign
|
||||
- assignOp
|
||||
- boolExprSimplify
|
||||
- builtinShadow
|
||||
- captLocal
|
||||
- caseOrder
|
||||
- commentedOutImport
|
||||
- defaultCaseOrder
|
||||
- dupArg
|
||||
- dupBranchBody
|
||||
- dupCase
|
||||
- dupSubExpr
|
||||
- elseif
|
||||
- emptyFallthrough
|
||||
- ifElseChain
|
||||
- importShadow
|
||||
- indexAlloc
|
||||
- methodExprCall
|
||||
- nestingReduce
|
||||
- offBy1
|
||||
- ptrToRefParam
|
||||
- regexpMust
|
||||
- singleCaseSwitch
|
||||
- sloppyLen
|
||||
- switchTrue
|
||||
- typeSwitchVar
|
||||
- typeUnparen
|
||||
- underef
|
||||
- unlambda
|
||||
- unnecessaryBlock
|
||||
- unslice
|
||||
- valSwap
|
||||
- wrapperFunc
|
||||
- yodaStyleExpr
|
||||
|
||||
|
||||
# linters that we should / shouldn't run
|
||||
linters:
|
||||
enable-all: true
|
||||
disable:
|
||||
- gochecknoglobals
|
||||
- lll
|
||||
- maligned
|
||||
- prealloc
|
||||
- wsl
|
||||
- gomnd
|
||||
- godox
|
||||
- goerr113
|
||||
- testpackage
|
||||
- godot
|
||||
- interfacer
|
||||
- goheader
|
||||
- noctx
|
||||
|
||||
# rules to deal with reported isues
|
||||
issues:
|
||||
# List of regexps of issue texts to exclude, empty list by default.
|
||||
# But independently from this option we use default exclude patterns,
|
||||
# it can be disabled by `exclude-use-default: false`. To list all
|
||||
# excluded by default patterns execute `golangci-lint run --help`
|
||||
exclude:
|
||||
|
||||
# Independently from option `exclude` we use default exclude patterns,
|
||||
# it can be disabled by this option. To list all
|
||||
# excluded by default patterns execute `golangci-lint run --help`.
|
||||
# Default value for this option is true.
|
||||
exclude-use-default: true
|
||||
|
||||
# Maximum issues count per one linter. Set to 0 to disable. Default is 50.
|
||||
max-per-linter: 0
|
||||
|
||||
# Maximum count of issues with the same text. Set to 0 to disable. Default is 3.
|
||||
max-same-issues: 0
|
||||
|
||||
# Show only new issues: if there are unstaged changes or untracked files,
|
||||
# only those changes are analyzed, else only changes in HEAD~ are analyzed.
|
||||
# It's a super-useful option for integration of golangci-lint into existing
|
||||
# large codebase. It's not practical to fix all existing issues at the moment
|
||||
# of integration: much better don't allow issues in new code.
|
||||
# Default is false.
|
||||
new: false
|
||||
|
||||
# Show only new issues created after git revision `REV`
|
||||
new-from-rev: "HEAD~1"
|
||||
38
.goreleaser.yml
Normal file
38
.goreleaser.yml
Normal file
@@ -0,0 +1,38 @@
|
||||
release:
|
||||
prerelease: auto
|
||||
name_template: "{{.ProjectName}} v{{.Version}}"
|
||||
|
||||
builds:
|
||||
- env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
- freebsd
|
||||
- windows
|
||||
- darwin
|
||||
- linux
|
||||
- dragonfly
|
||||
- netbsd
|
||||
- openbsd
|
||||
goarch:
|
||||
- amd64
|
||||
- arm
|
||||
- arm64
|
||||
- 386
|
||||
ldflags:
|
||||
- -s -w -X main.githash={{.ShortCommit}}
|
||||
|
||||
archives:
|
||||
-
|
||||
id: matterbridge
|
||||
builds:
|
||||
- matterbridge
|
||||
name_template: "{{ .Binary }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}"
|
||||
format: binary
|
||||
files:
|
||||
- none*
|
||||
replacements:
|
||||
386: 32bit
|
||||
amd64: 64bit
|
||||
|
||||
checksum:
|
||||
name_template: 'checksums.txt'
|
||||
49
.travis.yml
49
.travis.yml
@@ -1,49 +0,0 @@
|
||||
language: go
|
||||
go:
|
||||
#- 1.7.x
|
||||
- 1.9.x
|
||||
# - tip
|
||||
|
||||
# we have everything vendored
|
||||
install: true
|
||||
|
||||
env:
|
||||
- GOOS=linux GOARCH=amd64
|
||||
# - GOOS=windows GOARCH=amd64
|
||||
#- GOOS=linux GOARCH=arm
|
||||
|
||||
matrix:
|
||||
# It's ok if our code fails on unstable development versions of Go.
|
||||
allow_failures:
|
||||
- go: tip
|
||||
# Don't wait for tip tests to finish. Mark the test run green if the
|
||||
# tests pass on the stable versions of Go.
|
||||
fast_finish: true
|
||||
|
||||
notifications:
|
||||
email: false
|
||||
|
||||
before_script:
|
||||
- MY_VERSION=$(git describe --tags)
|
||||
- GO_FILES=$(find . -iname '*.go' | grep -v /vendor/) # All the .go files, excluding vendor/
|
||||
- PKGS=$(go list ./... | grep -v /vendor/) # All the import paths, excluding vendor/
|
||||
# - go get github.com/golang/lint/golint # Linter
|
||||
- go get honnef.co/go/tools/cmd/megacheck # Badass static analyzer/linter
|
||||
|
||||
# Anything in before_script: that returns a nonzero exit code will
|
||||
# flunk the build and immediately stop. It's sorta like having
|
||||
# set -e enabled in bash.
|
||||
script:
|
||||
- test -z $(gofmt -s -l $GO_FILES) # Fail if a .go file hasn't been formatted with gofmt
|
||||
- go test -v -race $PKGS # Run all the tests with the race detector enabled
|
||||
- go vet $PKGS # go vet is the official Go static analyzer
|
||||
- megacheck $PKGS # "go vet on steroids" + linter
|
||||
- /bin/bash ci/bintray.sh
|
||||
#- golint -set_exit_status $PKGS # one last linter
|
||||
|
||||
deploy:
|
||||
provider: bintray
|
||||
file: ci/deploy.json
|
||||
user: 42wim
|
||||
key:
|
||||
secure: "CeXXe6JOmt7HYR81MdWLua0ltQHhDdkIeRGBFbgd7hkb1wi8eF9DgpAcQrTso8NIlHNZmSAP46uhFgsRvkuezzX0ygalZ7DCJyAyn3sAMEh+UQSHV1WGThRehTtidqRGjetzsIGSwdrJOWil+XTfbO1Z8DGzfakhSuAZka8CM4BAoe3YeP9rYK8h+84x0GHfczvsLtXZ3mWLvQuwe4pK6+ItBCUg0ae7O7ZUpWHy0xQQkkWztY/6RAzXfaG7DuGjIw+20fhx3WOXRNpHCtZ6Bc3qERCpk0s1HhlQWlrN9wDaFTBWYwlvSnNgvxxMbNXJ6RrRJ0l0bA7FUswYwyroxhzrGLdzWDg8dHaQkypocngdalfhpsnoO9j3ApJhomUFJ3UoEq5nOGRUrKn8MPi+dP0zE4kNQ3e4VNa1ufNrvfpWolMg3xh8OXuhQdD5wIM5zFAbRJLqWSCVAjPq4DDPecmvXBOlIial7oa312lN5qnBnUjvAcxszZ+FUyDHT1Grxzna4tMwxY9obPzZUzm7359AOCCwIQFVB8GLqD2nwIstcXS0zGRz+fhviPipHuBa02q5bGUZwmkvrSNab0s8Jo7pCrel2Rz3nWPKaiCfq2WjbW1CLheSMkOQrjsdUd1hhbqNWFPUjJPInTc77NAKCfm5runv5uyowRLh4NNd0sI="
|
||||
23
Dockerfile
23
Dockerfile
@@ -1,11 +1,14 @@
|
||||
FROM alpine:edge
|
||||
ENTRYPOINT ["/bin/matterbridge"]
|
||||
FROM alpine AS builder
|
||||
|
||||
COPY . /go/src/github.com/42wim/matterbridge
|
||||
RUN apk update && apk add go git gcc musl-dev ca-certificates \
|
||||
&& cd /go/src/github.com/42wim/matterbridge \
|
||||
&& export GOPATH=/go \
|
||||
&& go get \
|
||||
&& go build -x -ldflags "-X main.githash=$(git log --pretty=format:'%h' -n 1)" -o /bin/matterbridge \
|
||||
&& rm -rf /go \
|
||||
&& apk del --purge git go gcc musl-dev
|
||||
COPY . /go/src/matterbridge
|
||||
RUN apk --no-cache add go git \
|
||||
&& cd /go/src/matterbridge \
|
||||
&& go build -mod vendor -ldflags "-X main.githash=$(git log --pretty=format:'%h' -n 1)" -o /bin/matterbridge
|
||||
|
||||
FROM alpine
|
||||
RUN apk --no-cache add ca-certificates mailcap
|
||||
COPY --from=builder /bin/matterbridge /bin/matterbridge
|
||||
RUN mkdir /etc/matterbridge \
|
||||
&& touch /etc/matterbridge/matterbridge.toml \
|
||||
&& ln -sf /matterbridge.toml /etc/matterbridge/matterbridge.toml
|
||||
ENTRYPOINT ["/bin/matterbridge", "-conf", "/etc/matterbridge/matterbridge.toml"]
|
||||
|
||||
360
README.md
360
README.md
@@ -1,87 +1,211 @@
|
||||
<div align="center">
|
||||
|
||||
# matterbridge
|
||||
Click on one of the badges below to join the chat
|
||||
|
||||
[](https://gitter.im/42wim/matterbridge) [](https://webchat.freenode.net/?channels=matterbridgechat) [](https://discord.gg/AkKPtrQ) [](https://riot.im/app/#/room/#matterbridge:matrix.org) [](https://join.slack.com/matterbridgechat/shared_invite/MjEwODMxNjU1NDMwLTE0OTk2MTU3NTMtMzZkZmRiNDZhOA) [](https://framateam.org/signup_user_complete/?id=tfqm33ggop8x3qgu4boeieta6e) 
|
||||
<br />
|
||||
**A simple chat bridge**<br />
|
||||
Letting people be where they want to be.<br />
|
||||
<sub>Bridges between a growing number of protocols. Click below to demo or join the development chat.</sub>
|
||||
|
||||
[](https://github.com/42wim/matterbridge/releases/latest) [](https://bintray.com/42wim/nightly/Matterbridge/_latestVersion)
|
||||
<sup>
|
||||
|
||||

|
||||
[Discord][mb-discord] |
|
||||
[Gitter][mb-gitter] |
|
||||
[IRC][mb-irc] |
|
||||
[Keybase][mb-keybase] |
|
||||
[Matrix][mb-matrix] |
|
||||
[Mattermost][mb-mattermost] |
|
||||
[MSTeams][mb-msteams] |
|
||||
[Rocket.Chat][mb-rocketchat] |
|
||||
[Slack][mb-slack] |
|
||||
[Telegram][mb-telegram] |
|
||||
[Twitch][mb-twitch] |
|
||||
[WhatsApp][mb-whatsapp] |
|
||||
[XMPP][mb-xmpp] |
|
||||
[Zulip][mb-zulip] |
|
||||
And more...
|
||||
</sup>
|
||||
|
||||
Simple bridge between Mattermost, IRC, XMPP, Gitter, Slack, Discord, Telegram, Rocket.Chat, Hipchat(via xmpp), Matrix and Steam.
|
||||
Has a REST API.
|
||||
---
|
||||
|
||||
[](https://github.com/42wim/matterbridge/releases/latest)
|
||||
[](https://codeclimate.com/github/42wim/matterbridge/maintainability)
|
||||
[](https://codeclimate.com/github/42wim/matterbridge/test_coverage)<br />
|
||||
|
||||
<hr />
|
||||
</div>
|
||||
<div align="right"><sup>
|
||||
|
||||
**Note:** Matter<em>most</em> isn't required to run matter<em>bridge</em>.</sup></div>
|
||||
|
||||
<p>
|
||||
<a href="https://www.digitalocean.com/">
|
||||
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/PoweredByDO/DO_Powered_by_Badge_blue.svg" width="201px">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
# Table of Contents
|
||||
* [Features](#features)
|
||||
* [Requirements](#requirements)
|
||||
* [Screenshots](https://github.com/42wim/matterbridge/wiki/)
|
||||
* [Installing](#installing)
|
||||
* [Binaries](#binaries)
|
||||
* [Building](#building)
|
||||
* [Configuration](#configuration)
|
||||
* [Howto](https://github.com/42wim/matterbridge/wiki/How-to-create-your-config)
|
||||
* [Examples](#examples)
|
||||
* [Running](#running)
|
||||
* [Docker](#docker)
|
||||
* [Changelog](#changelog)
|
||||
* [FAQ](#faq)
|
||||
* [Thanks](#thanks)
|
||||
|
||||
# Features
|
||||
* Relays public channel messages between multiple mattermost, IRC, XMPP, Gitter, Slack, Discord, Telegram, Rocket.Chat, Hipchat (via xmpp), Matrix and Steam.
|
||||
Pick and mix.
|
||||
* Support private groups on your mattermost/slack.
|
||||
* Allow for bridging the same bridges, which means you can eg bridge between multiple mattermosts.
|
||||
* The bridge is now a gateway which has support multiple in and out bridges. (and supports multiple gateways).
|
||||
* Edits and delete messages across bridges that support it (mattermost,slack,discord,gitter,telegram)
|
||||
* REST API to read/post messages to bridges (WIP).
|
||||
- [matterbridge](#matterbridge)
|
||||
- [Table of Contents](#table-of-contents)
|
||||
- [Features](#features)
|
||||
- [Natively supported](#natively-supported)
|
||||
- [3rd party via matterbridge api](#3rd-party-via-matterbridge-api)
|
||||
- [API](#api)
|
||||
- [Chat with us](#chat-with-us)
|
||||
- [Screenshots](#screenshots)
|
||||
- [Installing / upgrading](#installing--upgrading)
|
||||
- [Binaries](#binaries)
|
||||
- [Packages](#packages)
|
||||
- [Building](#building)
|
||||
- [Configuration](#configuration)
|
||||
- [Basic configuration](#basic-configuration)
|
||||
- [Settings](#settings)
|
||||
- [Advanced configuration](#advanced-configuration)
|
||||
- [Examples](#examples)
|
||||
- [Bridge mattermost (off-topic) - irc (#testing)](#bridge-mattermost-off-topic---irc-testing)
|
||||
- [Bridge slack (#general) - discord (general)](#bridge-slack-general---discord-general)
|
||||
- [Running](#running)
|
||||
- [Docker](#docker)
|
||||
- [Changelog](#changelog)
|
||||
- [FAQ](#faq)
|
||||
- [Related projects](#related-projects)
|
||||
- [Articles / Tutorials](#articles--tutorials)
|
||||
- [Thanks](#thanks)
|
||||
|
||||
# Requirements
|
||||
Accounts to one of the supported bridges
|
||||
* [Mattermost](https://github.com/mattermost/platform/) 3.8.x - 3.10.x, 4.x
|
||||
* [IRC](http://www.mirc.com/servers.html)
|
||||
* [XMPP](https://jabber.org)
|
||||
* [Gitter](https://gitter.im)
|
||||
* [Slack](https://slack.com)
|
||||
* [Discord](https://discordapp.com)
|
||||
* [Telegram](https://telegram.org)
|
||||
* [Hipchat](https://www.hipchat.com)
|
||||
* [Rocket.chat](https://rocket.chat)
|
||||
* [Matrix](https://matrix.org)
|
||||
* [Steam](https://store.steampowered.com/)
|
||||
## Features
|
||||
|
||||
# Screenshots
|
||||
See https://github.com/42wim/matterbridge/wiki
|
||||
- [Support bridging between any protocols](https://github.com/42wim/matterbridge/wiki/Features#support-bridging-between-any-protocols)
|
||||
- [Support multiple gateways(bridges) for your protocols](https://github.com/42wim/matterbridge/wiki/Features#support-multiple-gatewaysbridges-for-your-protocols)
|
||||
- [Message edits and deletes](https://github.com/42wim/matterbridge/wiki/Features#message-edits-and-deletes)
|
||||
- Preserves threading when possible
|
||||
- [Attachment / files handling](https://github.com/42wim/matterbridge/wiki/Features#attachment--files-handling)
|
||||
- [Username and avatar spoofing](https://github.com/42wim/matterbridge/wiki/Features#username-and-avatar-spoofing)
|
||||
- [Private groups](https://github.com/42wim/matterbridge/wiki/Features#private-groups)
|
||||
- [API](https://github.com/42wim/matterbridge/wiki/Features#api)
|
||||
|
||||
# Installing
|
||||
## Binaries
|
||||
* Latest stable release [v1.3.1](https://github.com/42wim/matterbridge/releases/latest)
|
||||
* Development releases (follows master) can be downloaded [here](https://dl.bintray.com/42wim/nightly/)
|
||||
### Natively supported
|
||||
|
||||
- [Discord](https://discordapp.com)
|
||||
- [Gitter](https://gitter.im)
|
||||
- [IRC](http://www.mirc.com/servers.html)
|
||||
- [Keybase](https://keybase.io)
|
||||
- [Matrix](https://matrix.org)
|
||||
- [Mattermost](https://github.com/mattermost/mattermost-server/)
|
||||
- [Microsoft Teams](https://teams.microsoft.com)
|
||||
- [Mumble](https://www.mumble.info/)
|
||||
- [Nextcloud Talk](https://nextcloud.com/talk/)
|
||||
- [Rocket.chat](https://rocket.chat)
|
||||
- [Slack](https://slack.com)
|
||||
- [Ssh-chat](https://github.com/shazow/ssh-chat)
|
||||
- ~~[Steam](https://store.steampowered.com/)~~
|
||||
- Not supported anymore, see [here](https://github.com/Philipp15b/go-steam/issues/94) for more info.
|
||||
- [Telegram](https://telegram.org)
|
||||
- [Twitch](https://twitch.tv)
|
||||
- [VK](https://vk.com/)
|
||||
- [WhatsApp](https://www.whatsapp.com/)
|
||||
- [XMPP](https://xmpp.org)
|
||||
- [Zulip](https://zulipchat.com)
|
||||
|
||||
### 3rd party via matterbridge api
|
||||
|
||||
- [Discourse](https://github.com/DeclanHoare/matterbabble)
|
||||
- [Facebook messenger](https://github.com/VictorNine/fbridge)
|
||||
- [Minecraft](https://github.com/elytra/MatterLink)
|
||||
- [Minecraft](https://github.com/raws/mattercraft)
|
||||
- [Reddit](https://github.com/bonehurtingjuice/mattereddit)
|
||||
- [Counter-Strike, half-life and more](https://forums.alliedmods.net/showthread.php?t=319430)
|
||||
- [MatterAMXX](https://github.com/GabeIggy/MatterAMXX)
|
||||
- [Vintage Story](https://github.com/NikkyAI/vs-matterbridge)
|
||||
|
||||
### API
|
||||
|
||||
The API is basic at the moment.
|
||||
More info and examples on the [wiki](https://github.com/42wim/matterbridge/wiki/Api).
|
||||
|
||||
Used by the projects below. Feel free to make a PR to add your project to this list.
|
||||
|
||||
- [MatterLink](https://github.com/elytra/MatterLink) (Matterbridge link for Minecraft Server chat)
|
||||
- [Minecraft](https://github.com/raws/mattercraft) (Matterbridge link for Minecraft Server chat)
|
||||
- [pyCord](https://github.com/NikkyAI/pyCord) (crossplatform chatbot)
|
||||
- [Mattereddit](https://github.com/bonehurtingjuice/mattereddit) (Reddit chat support)
|
||||
- [fbridge](https://github.com/VictorNine/fbridge) (Facebook messenger support)
|
||||
- [matterbabble](https://github.com/DeclanHoare/matterbabble) (Discourse support)
|
||||
- [MatterAMXX](https://forums.alliedmods.net/showthread.php?t=319430) (Counter-Strike, half-life and more via AMXX mod)
|
||||
- [Vintage Story](https://github.com/NikkyAI/vs-matterbridge)
|
||||
|
||||
## Chat with us
|
||||
|
||||
Questions or want to test on your favorite platform? Join below:
|
||||
|
||||
- [Discord][mb-discord]
|
||||
- [Gitter][mb-gitter]
|
||||
- [IRC][mb-irc]
|
||||
- [Keybase][mb-keybase]
|
||||
- [Matrix][mb-matrix]
|
||||
- [Mattermost][mb-mattermost]
|
||||
- [Rocket.Chat][mb-rocketchat]
|
||||
- [Slack][mb-slack]
|
||||
- [Telegram][mb-telegram]
|
||||
- [Twitch][mb-twitch]
|
||||
- [XMPP][mb-xmpp] (matterbridge@conference.jabber.de)
|
||||
- [Zulip][mb-zulip]
|
||||
|
||||
## Screenshots
|
||||
|
||||
See <https://github.com/42wim/matterbridge/wiki>
|
||||
|
||||
## Installing / upgrading
|
||||
|
||||
### Binaries
|
||||
|
||||
- Latest stable release [v1.22.0](https://github.com/42wim/matterbridge/releases/latest)
|
||||
- Development releases (follows master) can be downloaded [here](https://github.com/42wim/matterbridge/actions) selecting the latest green build and then artifacts.
|
||||
|
||||
To install or upgrade just download the latest [binary](https://github.com/42wim/matterbridge/releases/latest). On \*nix platforms you may need to make the binary executable - you can do this by running `chmod a+x` on the binary (example: `chmod a+x matterbridge-1.20.0-linux-64bit`). After downloading (and making the binary executable, if necessary), follow the instructions on the [howto](https://github.com/42wim/matterbridge/wiki/How-to-create-your-config) for a step by step walkthrough for creating your configuration.
|
||||
|
||||
### Packages
|
||||
|
||||
- [Overview](https://repology.org/metapackage/matterbridge/versions)
|
||||
- [snap](https://snapcraft.io/matterbridge)
|
||||
|
||||
## Building
|
||||
Go 1.7+ is required. Make sure you have [Go](https://golang.org/doc/install) properly installed, including setting up your [GOPATH] (https://golang.org/doc/code.html#GOPATH)
|
||||
|
||||
```
|
||||
cd $GOPATH
|
||||
Most people just want to use binaries, you can find those [here](https://github.com/42wim/matterbridge/releases/latest)
|
||||
|
||||
If you really want to build from source, follow these instructions:
|
||||
Go 1.13+ is required. Make sure you have [Go](https://golang.org/doc/install) properly installed.
|
||||
|
||||
```bash
|
||||
go get github.com/42wim/matterbridge
|
||||
```
|
||||
|
||||
You should now have matterbridge binary in the bin directory:
|
||||
You should now have matterbridge binary in the ~/go/bin directory:
|
||||
|
||||
```
|
||||
$ ls bin/
|
||||
```bash
|
||||
$ ls ~/go/bin/
|
||||
matterbridge
|
||||
```
|
||||
|
||||
# Configuration
|
||||
## Basic configuration
|
||||
## Configuration
|
||||
|
||||
### Basic configuration
|
||||
|
||||
See [howto](https://github.com/42wim/matterbridge/wiki/How-to-create-your-config) for a step by step walkthrough for creating your configuration.
|
||||
|
||||
## Advanced configuration
|
||||
* [matterbridge.toml.sample](https://github.com/42wim/matterbridge/blob/master/matterbridge.toml.sample) for documentation and an example.
|
||||
### Settings
|
||||
|
||||
## Examples
|
||||
### Bridge mattermost (off-topic) - irc (#testing)
|
||||
```
|
||||
All possible [settings](https://github.com/42wim/matterbridge/wiki/Settings) for each bridge.
|
||||
|
||||
### Advanced configuration
|
||||
|
||||
- [matterbridge.toml.sample](https://github.com/42wim/matterbridge/blob/master/matterbridge.toml.sample) for documentation and an example.
|
||||
|
||||
### Examples
|
||||
|
||||
#### Bridge mattermost (off-topic) - irc (#testing)
|
||||
|
||||
```toml
|
||||
[irc]
|
||||
[irc.freenode]
|
||||
Server="irc.freenode.net:6667"
|
||||
@@ -108,8 +232,9 @@ enable=true
|
||||
channel="off-topic"
|
||||
```
|
||||
|
||||
### Bridge slack (#general) - discord (general)
|
||||
```
|
||||
#### Bridge slack (#general) - discord (general)
|
||||
|
||||
```toml
|
||||
[slack]
|
||||
[slack.test]
|
||||
Token="yourslacktoken"
|
||||
@@ -136,11 +261,11 @@ RemoteNickFormat="[{PROTOCOL}/{BRIDGE}] <{NICK}> "
|
||||
channel = "general"
|
||||
```
|
||||
|
||||
# Running
|
||||
## Running
|
||||
|
||||
See [howto](https://github.com/42wim/matterbridge/wiki/How-to-create-your-config) for a step by step walkthrough for creating your configuration.
|
||||
|
||||
```
|
||||
```bash
|
||||
Usage of ./matterbridge:
|
||||
-conf string
|
||||
config file (default "matterbridge.toml")
|
||||
@@ -152,33 +277,96 @@ Usage of ./matterbridge:
|
||||
show version
|
||||
```
|
||||
|
||||
## Docker
|
||||
Create your matterbridge.toml file locally eg in ```/tmp/matterbridge.toml```
|
||||
```
|
||||
docker run -ti -v /tmp/matterbridge.toml:/matterbridge.toml 42wim/matterbridge
|
||||
```
|
||||
### Docker
|
||||
|
||||
Please take a look at the [Docker Wiki page](https://github.com/42wim/matterbridge/wiki/Deploy:-Docker) for more information.
|
||||
|
||||
## Changelog
|
||||
|
||||
# Changelog
|
||||
See [changelog.md](https://github.com/42wim/matterbridge/blob/master/changelog.md)
|
||||
|
||||
# FAQ
|
||||
## FAQ
|
||||
|
||||
See [FAQ](https://github.com/42wim/matterbridge/wiki/FAQ)
|
||||
|
||||
Want to tip ?
|
||||
* eth: 0xb3f9b5387c66ad6be892bcb7bbc67862f3abc16f
|
||||
* btc: 1N7cKHj5SfqBHBzDJ6kad4BzeqUBBS2zhs
|
||||
## Related projects
|
||||
|
||||
- [jwflory/ansible-role-matterbridge](https://galaxy.ansible.com/jwflory/matterbridge) (Ansible role to simplify deploying Matterbridge)
|
||||
- [matterbridge autoconfig](https://github.com/patcon/matterbridge-autoconfig)
|
||||
- [matterbridge config viewer](https://github.com/patcon/matterbridge-heroku-viewer)
|
||||
- [matterbridge-heroku](https://github.com/cadecairos/matterbridge-heroku)
|
||||
- [mattereddit](https://github.com/bonehurtingjuice/mattereddit)
|
||||
- [matterlink](https://github.com/elytra/MatterLink)
|
||||
- [mattermost-plugin](https://github.com/matterbridge/mattermost-plugin) - Run matterbridge as a plugin in mattermost
|
||||
- [pyCord](https://github.com/NikkyAI/pyCord) (crossplatform chatbot)
|
||||
- [fbridge](https://github.com/VictorNine/fbridge) (Facebook messenger support)
|
||||
- [isla](https://github.com/alphachung/isla) (Bot for Discord-Telegram groups used alongside matterbridge)
|
||||
- [matterbabble](https://github.com/DeclanHoare/matterbabble) (Connect Discourse threads to Matterbridge)
|
||||
- [nextcloud talk](https://github.com/nextcloud/talk_matterbridge) (Integrates matterbridge in Nextcloud Talk)
|
||||
- [mattercraft](https://github.com/raws/mattercraft) (Minecraft bridge)
|
||||
- [vs-matterbridge](https://github.com/NikkyAI/vs-matterbridge) (Vintage Story bridge)
|
||||
|
||||
## Articles / Tutorials
|
||||
|
||||
- [matterbridge on kubernetes](https://medium.freecodecamp.org/using-kubernetes-to-deploy-a-chat-gateway-or-when-technology-works-like-its-supposed-to-a169a8cd69a3)
|
||||
- <https://mattermost.com/blog/connect-irc-to-mattermost/>
|
||||
- <https://blog.valvin.fr/2016/09/17/mattermost-et-un-channel-irc-cest-possible/>
|
||||
- <https://blog.brightscout.com/top-10-mattermost-integrations/>
|
||||
- <https://www.algoo.fr/blog/2018/01/19/recouvrez-votre-liberte-en-quittant-slack-pour-un-mattermost-auto-heberge/>
|
||||
- <https://kopano.com/blog/matterbridge-bridging-mattermost-chat/>
|
||||
- <https://www.stitcher.com/s/?eid=52382713>
|
||||
- <https://daniele.tech/2019/02/how-to-use-matterbridge-to-connect-2-different-slack-workspaces/>
|
||||
- <https://userlinux.net/mattermost-and-matterbridge.html>
|
||||
- <https://nextcloud.com/blog/bridging-chat-services-in-talk/>
|
||||
- Youtube: [whatsapp - telegram bridging](https://www.youtube.com/watch?v=W-VXISoKtNc)
|
||||
|
||||
## Thanks
|
||||
|
||||
<p>This project is supported by:</p>
|
||||
<p>
|
||||
<a href="https://www.digitalocean.com/">
|
||||
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" width="201px">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
# Thanks
|
||||
Matterbridge wouldn't exist without these libraries:
|
||||
* discord - https://github.com/bwmarrin/discordgo
|
||||
* echo - https://github.com/labstack/echo
|
||||
* gitter - https://github.com/sromku/go-gitter
|
||||
* gops - https://github.com/google/gops
|
||||
* irc - https://github.com/thoj/go-ircevent
|
||||
* mattermost - https://github.com/mattermost/platform
|
||||
* matrix - https://github.com/matrix-org/gomatrix
|
||||
* slack - https://github.com/nlopes/slack
|
||||
* steam - https://github.com/Philipp15b/go-steam
|
||||
* telegram - https://github.com/go-telegram-bot-api/telegram-bot-api
|
||||
* xmpp - https://github.com/mattn/go-xmpp
|
||||
|
||||
- discord - <https://github.com/bwmarrin/discordgo>
|
||||
- echo - <https://github.com/labstack/echo>
|
||||
- gitter - <https://github.com/sromku/go-gitter>
|
||||
- gops - <https://github.com/google/gops>
|
||||
- gozulipbot - <https://github.com/ifo/gozulipbot>
|
||||
- gumble - <https://github.com/layeh/gumble>
|
||||
- irc - <https://github.com/lrstanley/girc>
|
||||
- keybase - <https://github.com/keybase/go-keybase-chat-bot>
|
||||
- matrix - <https://github.com/matrix-org/gomatrix>
|
||||
- mattermost - <https://github.com/mattermost/mattermost-server>
|
||||
- msgraph.go - <https://github.com/yaegashi/msgraph.go>
|
||||
- mumble - <https://github.com/layeh/gumble>
|
||||
- nctalk - <https://github.com/gary-kim/go-nc-talk>
|
||||
- slack - <https://github.com/nlopes/slack>
|
||||
- sshchat - <https://github.com/shazow/ssh-chat>
|
||||
- steam - <https://github.com/Philipp15b/go-steam>
|
||||
- telegram - <https://github.com/go-telegram-bot-api/telegram-bot-api>
|
||||
- tengo - <https://github.com/d5/tengo>
|
||||
- vk - <https://github.com/SevereCloud/vksdk>
|
||||
- whatsapp - <https://github.com/Rhymen/go-whatsapp>
|
||||
- xmpp - <https://github.com/mattn/go-xmpp>
|
||||
- zulip - <https://github.com/ifo/gozulipbot>
|
||||
|
||||
<!-- Links -->
|
||||
|
||||
[mb-discord]: https://discord.gg/AkKPtrQ
|
||||
[mb-gitter]: https://gitter.im/42wim/matterbridge
|
||||
[mb-irc]: https://webchat.freenode.net/?channels=matterbridgechat
|
||||
[mb-keybase]: https://keybase.io/team/matterbridge
|
||||
[mb-matrix]: https://riot.im/app/#/room/#matterbridge:matrix.org
|
||||
[mb-mattermost]: https://framateam.org/signup_user_complete/?id=tfqm33ggop8x3qgu4boeieta6e
|
||||
[mb-msteams]: https://teams.microsoft.com/join/hj92x75gd3y7
|
||||
[mb-rocketchat]: https://open.rocket.chat/channel/matterbridge
|
||||
[mb-slack]: https://join.slack.com/t/matterbridgechat/shared_invite/zt-2ourq2h2-7YvyYBq2WFGC~~zEzA68_Q
|
||||
[mb-telegram]: https://t.me/Matterbridge
|
||||
[mb-twitch]: https://www.twitch.tv/matterbridge
|
||||
[mb-whatsapp]: https://www.whatsapp.com/
|
||||
[mb-xmpp]: https://inverse.chat/
|
||||
[mb-zulip]: https://matterbridge.zulipchat.com/register/
|
||||
|
||||
@@ -1,24 +1,28 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
log "github.com/Sirupsen/logrus"
|
||||
"github.com/labstack/echo"
|
||||
"github.com/labstack/echo/middleware"
|
||||
"github.com/zfjagann/golang-ring"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gopkg.in/olahol/melody.v1"
|
||||
|
||||
"github.com/42wim/matterbridge/bridge"
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
ring "github.com/zfjagann/golang-ring"
|
||||
)
|
||||
|
||||
type Api struct {
|
||||
Config *config.Protocol
|
||||
Remote chan config.Message
|
||||
Account string
|
||||
type API struct {
|
||||
Messages ring.Ring
|
||||
sync.RWMutex
|
||||
*bridge.Config
|
||||
mrouter *melody.Melody
|
||||
}
|
||||
|
||||
type ApiMessage struct {
|
||||
type Message struct {
|
||||
Text string `json:"text"`
|
||||
Username string `json:"username"`
|
||||
UserID string `json:"userid"`
|
||||
@@ -26,80 +30,178 @@ type ApiMessage struct {
|
||||
Gateway string `json:"gateway"`
|
||||
}
|
||||
|
||||
var flog *log.Entry
|
||||
var protocol = "api"
|
||||
|
||||
func init() {
|
||||
flog = log.WithFields(log.Fields{"module": protocol})
|
||||
}
|
||||
|
||||
func New(cfg config.Protocol, account string, c chan config.Message) *Api {
|
||||
b := &Api{}
|
||||
func New(cfg *bridge.Config) bridge.Bridger {
|
||||
b := &API{Config: cfg}
|
||||
e := echo.New()
|
||||
e.HideBanner = true
|
||||
e.HidePort = true
|
||||
|
||||
b.mrouter = melody.New()
|
||||
b.mrouter.HandleMessage(func(s *melody.Session, msg []byte) {
|
||||
message := config.Message{}
|
||||
err := json.Unmarshal(msg, &message)
|
||||
if err != nil {
|
||||
b.Log.Errorf("failed to decode message from byte[] '%s'", string(msg))
|
||||
return
|
||||
}
|
||||
b.handleWebsocketMessage(message, s)
|
||||
})
|
||||
b.mrouter.HandleConnect(func(session *melody.Session) {
|
||||
greet := b.getGreeting()
|
||||
data, err := json.Marshal(greet)
|
||||
if err != nil {
|
||||
b.Log.Errorf("failed to encode message '%v'", greet)
|
||||
return
|
||||
}
|
||||
err = session.Write(data)
|
||||
if err != nil {
|
||||
b.Log.Errorf("failed to write message '%s'", string(data))
|
||||
return
|
||||
}
|
||||
// TODO: send message history buffer from `b.Messages` here
|
||||
})
|
||||
|
||||
b.Messages = ring.Ring{}
|
||||
b.Messages.SetCapacity(cfg.Buffer)
|
||||
b.Config = &cfg
|
||||
b.Account = account
|
||||
b.Remote = c
|
||||
if b.Config.Token != "" {
|
||||
if b.GetInt("Buffer") != 0 {
|
||||
b.Messages.SetCapacity(b.GetInt("Buffer"))
|
||||
}
|
||||
if b.GetString("Token") != "" {
|
||||
e.Use(middleware.KeyAuth(func(key string, c echo.Context) (bool, error) {
|
||||
return key == b.Config.Token, nil
|
||||
return key == b.GetString("Token"), nil
|
||||
}))
|
||||
}
|
||||
|
||||
// Set RemoteNickFormat to a sane default
|
||||
if !b.IsKeySet("RemoteNickFormat") {
|
||||
b.Log.Debugln("RemoteNickFormat is unset, defaulting to \"{NICK}\"")
|
||||
b.Config.Config.Viper().Set(b.GetConfigKey("RemoteNickFormat"), "{NICK}")
|
||||
}
|
||||
|
||||
e.GET("/api/health", b.handleHealthcheck)
|
||||
e.GET("/api/messages", b.handleMessages)
|
||||
e.GET("/api/stream", b.handleStream)
|
||||
e.GET("/api/websocket", b.handleWebsocket)
|
||||
e.POST("/api/message", b.handlePostMessage)
|
||||
go func() {
|
||||
flog.Fatal(e.Start(cfg.BindAddress))
|
||||
if b.GetString("BindAddress") == "" {
|
||||
b.Log.Fatalf("No BindAddress configured.")
|
||||
}
|
||||
b.Log.Infof("Listening on %s", b.GetString("BindAddress"))
|
||||
b.Log.Fatal(e.Start(b.GetString("BindAddress")))
|
||||
}()
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *Api) Connect() error {
|
||||
func (b *API) Connect() error {
|
||||
return nil
|
||||
}
|
||||
func (b *Api) Disconnect() error {
|
||||
return nil
|
||||
|
||||
}
|
||||
func (b *Api) JoinChannel(channel config.ChannelInfo) error {
|
||||
func (b *API) Disconnect() error {
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
func (b *Api) Send(msg config.Message) (string, error) {
|
||||
func (b *API) JoinChannel(channel config.ChannelInfo) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *API) Send(msg config.Message) (string, error) {
|
||||
b.Lock()
|
||||
defer b.Unlock()
|
||||
// ignore delete messages
|
||||
if msg.Event == config.EVENT_MSG_DELETE {
|
||||
if msg.Event == config.EventMsgDelete {
|
||||
return "", nil
|
||||
}
|
||||
b.Messages.Enqueue(&msg)
|
||||
b.Log.Debugf("enqueueing message from %s on ring buffer", msg.Username)
|
||||
b.Messages.Enqueue(msg)
|
||||
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
b.Log.Errorf("failed to encode message '%s'", msg)
|
||||
}
|
||||
_ = b.mrouter.Broadcast(data)
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (b *Api) handlePostMessage(c echo.Context) error {
|
||||
message := &ApiMessage{}
|
||||
if err := c.Bind(message); err != nil {
|
||||
func (b *API) handleHealthcheck(c echo.Context) error {
|
||||
return c.String(http.StatusOK, "OK")
|
||||
}
|
||||
|
||||
func (b *API) handlePostMessage(c echo.Context) error {
|
||||
message := config.Message{}
|
||||
if err := c.Bind(&message); err != nil {
|
||||
return err
|
||||
}
|
||||
flog.Debugf("Sending message from %s on %s to gateway", message.Username, "api")
|
||||
b.Remote <- config.Message{
|
||||
Text: message.Text,
|
||||
Username: message.Username,
|
||||
UserID: message.UserID,
|
||||
Channel: "api",
|
||||
Avatar: message.Avatar,
|
||||
Account: b.Account,
|
||||
Gateway: message.Gateway,
|
||||
Protocol: "api",
|
||||
}
|
||||
// these values are fixed
|
||||
message.Channel = "api"
|
||||
message.Protocol = "api"
|
||||
message.Account = b.Account
|
||||
message.ID = ""
|
||||
message.Timestamp = time.Now()
|
||||
b.Log.Debugf("Sending message from %s on %s to gateway", message.Username, "api")
|
||||
b.Remote <- message
|
||||
return c.JSON(http.StatusOK, message)
|
||||
}
|
||||
|
||||
func (b *Api) handleMessages(c echo.Context) error {
|
||||
func (b *API) handleMessages(c echo.Context) error {
|
||||
b.Lock()
|
||||
defer b.Unlock()
|
||||
c.JSONPretty(http.StatusOK, b.Messages.Values(), " ")
|
||||
b.Messages = ring.Ring{}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *API) getGreeting() config.Message {
|
||||
return config.Message{
|
||||
Event: config.EventAPIConnected,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func (b *API) handleStream(c echo.Context) error {
|
||||
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
||||
c.Response().WriteHeader(http.StatusOK)
|
||||
greet := b.getGreeting()
|
||||
if err := json.NewEncoder(c.Response()).Encode(greet); err != nil {
|
||||
return err
|
||||
}
|
||||
c.Response().Flush()
|
||||
for {
|
||||
// TODO: this causes issues, messages should be broadcasted to all connected clients
|
||||
msg := b.Messages.Dequeue()
|
||||
if msg != nil {
|
||||
if err := json.NewEncoder(c.Response()).Encode(msg); err != nil {
|
||||
return err
|
||||
}
|
||||
c.Response().Flush()
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *API) handleWebsocketMessage(message config.Message, s *melody.Session) {
|
||||
message.Channel = "api"
|
||||
message.Protocol = "api"
|
||||
message.Account = b.Account
|
||||
message.ID = ""
|
||||
message.Timestamp = time.Now()
|
||||
|
||||
data, err := json.Marshal(message)
|
||||
if err != nil {
|
||||
b.Log.Errorf("failed to encode message for loopback '%v'", message)
|
||||
return
|
||||
}
|
||||
_ = b.mrouter.BroadcastOthers(data, s)
|
||||
|
||||
b.Log.Debugf("Sending websocket message from %s on %s to gateway", message.Username, "api")
|
||||
b.Remote <- message
|
||||
}
|
||||
|
||||
func (b *API) handleWebsocket(c echo.Context) error {
|
||||
err := b.mrouter.HandleRequest(c.Response(), c.Request())
|
||||
if err != nil {
|
||||
b.Log.Errorf("error in websocket handling '%v'", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
163
bridge/bridge.go
163
bridge/bridge.go
@@ -1,21 +1,13 @@
|
||||
package bridge
|
||||
|
||||
import (
|
||||
"github.com/42wim/matterbridge/bridge/api"
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
"github.com/42wim/matterbridge/bridge/discord"
|
||||
"github.com/42wim/matterbridge/bridge/gitter"
|
||||
"github.com/42wim/matterbridge/bridge/irc"
|
||||
"github.com/42wim/matterbridge/bridge/matrix"
|
||||
"github.com/42wim/matterbridge/bridge/mattermost"
|
||||
"github.com/42wim/matterbridge/bridge/rocketchat"
|
||||
"github.com/42wim/matterbridge/bridge/slack"
|
||||
"github.com/42wim/matterbridge/bridge/steam"
|
||||
"github.com/42wim/matterbridge/bridge/telegram"
|
||||
"github.com/42wim/matterbridge/bridge/xmpp"
|
||||
log "github.com/Sirupsen/logrus"
|
||||
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type Bridger interface {
|
||||
@@ -26,75 +18,64 @@ type Bridger interface {
|
||||
}
|
||||
|
||||
type Bridge struct {
|
||||
Config config.Protocol
|
||||
Bridger
|
||||
Name string
|
||||
Account string
|
||||
Protocol string
|
||||
Channels map[string]config.ChannelInfo
|
||||
Joined map[string]bool
|
||||
*sync.RWMutex
|
||||
|
||||
Name string
|
||||
Account string
|
||||
Protocol string
|
||||
Channels map[string]config.ChannelInfo
|
||||
Joined map[string]bool
|
||||
ChannelMembers *config.ChannelMembers
|
||||
Log *logrus.Entry
|
||||
Config config.Config
|
||||
General *config.Protocol
|
||||
}
|
||||
|
||||
func New(cfg *config.Config, bridge *config.Bridge, c chan config.Message) *Bridge {
|
||||
b := new(Bridge)
|
||||
b.Channels = make(map[string]config.ChannelInfo)
|
||||
type Config struct {
|
||||
*Bridge
|
||||
|
||||
Remote chan config.Message
|
||||
}
|
||||
|
||||
// Factory is the factory function to create a bridge
|
||||
type Factory func(*Config) Bridger
|
||||
|
||||
func New(bridge *config.Bridge) *Bridge {
|
||||
accInfo := strings.Split(bridge.Account, ".")
|
||||
if len(accInfo) != 2 {
|
||||
log.Fatalf("config failure, account incorrect: %s", bridge.Account)
|
||||
}
|
||||
|
||||
protocol := accInfo[0]
|
||||
name := accInfo[1]
|
||||
b.Name = name
|
||||
b.Protocol = protocol
|
||||
b.Account = bridge.Account
|
||||
b.Joined = make(map[string]bool)
|
||||
|
||||
// override config from environment
|
||||
config.OverrideCfgFromEnv(cfg, protocol, name)
|
||||
switch protocol {
|
||||
case "mattermost":
|
||||
b.Config = cfg.Mattermost[name]
|
||||
b.Bridger = bmattermost.New(cfg.Mattermost[name], bridge.Account, c)
|
||||
case "irc":
|
||||
b.Config = cfg.IRC[name]
|
||||
b.Bridger = birc.New(cfg.IRC[name], bridge.Account, c)
|
||||
case "gitter":
|
||||
b.Config = cfg.Gitter[name]
|
||||
b.Bridger = bgitter.New(cfg.Gitter[name], bridge.Account, c)
|
||||
case "slack":
|
||||
b.Config = cfg.Slack[name]
|
||||
b.Bridger = bslack.New(cfg.Slack[name], bridge.Account, c)
|
||||
case "xmpp":
|
||||
b.Config = cfg.Xmpp[name]
|
||||
b.Bridger = bxmpp.New(cfg.Xmpp[name], bridge.Account, c)
|
||||
case "discord":
|
||||
b.Config = cfg.Discord[name]
|
||||
b.Bridger = bdiscord.New(cfg.Discord[name], bridge.Account, c)
|
||||
case "telegram":
|
||||
b.Config = cfg.Telegram[name]
|
||||
b.Bridger = btelegram.New(cfg.Telegram[name], bridge.Account, c)
|
||||
case "rocketchat":
|
||||
b.Config = cfg.Rocketchat[name]
|
||||
b.Bridger = brocketchat.New(cfg.Rocketchat[name], bridge.Account, c)
|
||||
case "matrix":
|
||||
b.Config = cfg.Matrix[name]
|
||||
b.Bridger = bmatrix.New(cfg.Matrix[name], bridge.Account, c)
|
||||
case "steam":
|
||||
b.Config = cfg.Steam[name]
|
||||
b.Bridger = bsteam.New(cfg.Steam[name], bridge.Account, c)
|
||||
case "api":
|
||||
b.Config = cfg.Api[name]
|
||||
b.Bridger = api.New(cfg.Api[name], bridge.Account, c)
|
||||
return &Bridge{
|
||||
RWMutex: new(sync.RWMutex),
|
||||
Channels: make(map[string]config.ChannelInfo),
|
||||
Name: name,
|
||||
Protocol: protocol,
|
||||
Account: bridge.Account,
|
||||
Joined: make(map[string]bool),
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *Bridge) JoinChannels() error {
|
||||
err := b.joinChannels(b.Channels, b.Joined)
|
||||
return err
|
||||
return b.joinChannels(b.Channels, b.Joined)
|
||||
}
|
||||
|
||||
// SetChannelMembers sets the newMembers to the bridge ChannelMembers
|
||||
func (b *Bridge) SetChannelMembers(newMembers *config.ChannelMembers) {
|
||||
b.Lock()
|
||||
b.ChannelMembers = newMembers
|
||||
b.Unlock()
|
||||
}
|
||||
|
||||
func (b *Bridge) joinChannels(channels map[string]config.ChannelInfo, exists map[string]bool) error {
|
||||
for ID, channel := range channels {
|
||||
if !exists[ID] {
|
||||
log.Infof("%s: joining %s (%s)", b.Account, channel.Name, ID)
|
||||
b.Log.Infof("%s: joining %s (ID: %s)", b.Account, channel.Name, ID)
|
||||
time.Sleep(time.Duration(b.GetInt("JoinDelay")) * time.Millisecond)
|
||||
err := b.JoinChannel(channel)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -104,3 +85,51 @@ func (b *Bridge) joinChannels(channels map[string]config.ChannelInfo, exists map
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bridge) GetConfigKey(key string) string {
|
||||
return b.Account + "." + key
|
||||
}
|
||||
|
||||
func (b *Bridge) IsKeySet(key string) bool {
|
||||
return b.Config.IsKeySet(b.GetConfigKey(key)) || b.Config.IsKeySet("general."+key)
|
||||
}
|
||||
|
||||
func (b *Bridge) GetBool(key string) bool {
|
||||
val, ok := b.Config.GetBool(b.GetConfigKey(key))
|
||||
if !ok {
|
||||
val, _ = b.Config.GetBool("general." + key)
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
func (b *Bridge) GetInt(key string) int {
|
||||
val, ok := b.Config.GetInt(b.GetConfigKey(key))
|
||||
if !ok {
|
||||
val, _ = b.Config.GetInt("general." + key)
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
func (b *Bridge) GetString(key string) string {
|
||||
val, ok := b.Config.GetString(b.GetConfigKey(key))
|
||||
if !ok {
|
||||
val, _ = b.Config.GetString("general." + key)
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
func (b *Bridge) GetStringSlice(key string) []string {
|
||||
val, ok := b.Config.GetStringSlice(b.GetConfigKey(key))
|
||||
if !ok {
|
||||
val, _ = b.Config.GetStringSlice("general." + key)
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
func (b *Bridge) GetStringSlice2D(key string) [][]string {
|
||||
val, ok := b.Config.GetStringSlice2D(b.GetConfigKey(key))
|
||||
if !ok {
|
||||
val, _ = b.Config.GetStringSlice2D("general." + key)
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
@@ -1,22 +1,36 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"github.com/BurntSushi/toml"
|
||||
"log"
|
||||
"bytes"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"reflect"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
const (
|
||||
EVENT_JOIN_LEAVE = "join_leave"
|
||||
EVENT_FAILURE = "failure"
|
||||
EVENT_REJOIN_CHANNELS = "rejoin_channels"
|
||||
EVENT_USER_ACTION = "user_action"
|
||||
EVENT_MSG_DELETE = "msg_delete"
|
||||
EventJoinLeave = "join_leave"
|
||||
EventTopicChange = "topic_change"
|
||||
EventFailure = "failure"
|
||||
EventFileFailureSize = "file_failure_size"
|
||||
EventAvatarDownload = "avatar_download"
|
||||
EventRejoinChannels = "rejoin_channels"
|
||||
EventUserAction = "user_action"
|
||||
EventMsgDelete = "msg_delete"
|
||||
EventAPIConnected = "api_connected"
|
||||
EventUserTyping = "user_typing"
|
||||
EventGetChannelMembers = "get_channel_members"
|
||||
EventNoticeIRC = "notice_irc"
|
||||
)
|
||||
|
||||
const ParentIDNotFound = "msg-parent-not-found"
|
||||
|
||||
type Message struct {
|
||||
Text string `json:"text"`
|
||||
Channel string `json:"channel"`
|
||||
@@ -27,14 +41,28 @@ type Message struct {
|
||||
Event string `json:"event"`
|
||||
Protocol string `json:"protocol"`
|
||||
Gateway string `json:"gateway"`
|
||||
ParentID string `json:"parent_id"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
ID string `json:"id"`
|
||||
Extra map[string][]interface{}
|
||||
}
|
||||
|
||||
func (m Message) ParentNotFound() bool {
|
||||
return m.ParentID == ParentIDNotFound
|
||||
}
|
||||
|
||||
func (m Message) ParentValid() bool {
|
||||
return m.ParentID != "" && !m.ParentNotFound()
|
||||
}
|
||||
|
||||
type FileInfo struct {
|
||||
Name string
|
||||
Data *[]byte
|
||||
Name string
|
||||
Data *[]byte
|
||||
Comment string
|
||||
URL string
|
||||
Size int64
|
||||
Avatar bool
|
||||
SHA string
|
||||
}
|
||||
|
||||
type ChannelInfo struct {
|
||||
@@ -46,57 +74,108 @@ type ChannelInfo struct {
|
||||
Options ChannelOptions
|
||||
}
|
||||
|
||||
type ChannelMember struct {
|
||||
Username string
|
||||
Nick string
|
||||
UserID string
|
||||
ChannelID string
|
||||
ChannelName string
|
||||
}
|
||||
|
||||
type ChannelMembers []ChannelMember
|
||||
|
||||
type Protocol struct {
|
||||
AuthCode string // steam
|
||||
BindAddress string // mattermost, slack // DEPRECATED
|
||||
Buffer int // api
|
||||
Charset string // irc
|
||||
ClientID string // msteams
|
||||
ColorNicks bool // only irc for now
|
||||
Debug bool // general
|
||||
DebugLevel int // only for irc now
|
||||
DisableWebPagePreview bool // telegram
|
||||
EditSuffix string // mattermost, slack, discord, telegram, gitter
|
||||
EditDisable bool // mattermost, slack, discord, telegram, gitter
|
||||
HTMLDisable bool // matrix
|
||||
IconURL string // mattermost, slack
|
||||
IgnoreFailureOnStart bool // general
|
||||
IgnoreNicks string // all protocols
|
||||
IgnoreMessages string // all protocols
|
||||
Jid string // xmpp
|
||||
JoinDelay string // all protocols
|
||||
Label string // all protocols
|
||||
Login string // mattermost, matrix
|
||||
Muc string // xmpp
|
||||
Name string // all protocols
|
||||
Nick string // all protocols
|
||||
NickFormatter string // mattermost, slack
|
||||
NickServNick string // IRC
|
||||
NickServUsername string // IRC
|
||||
NickServPassword string // IRC
|
||||
NicksPerRow int // mattermost, slack
|
||||
NoHomeServerSuffix bool // matrix
|
||||
NoTLS bool // mattermost
|
||||
Password string // IRC,mattermost,XMPP,matrix
|
||||
PrefixMessagesWithNick bool // mattemost, slack
|
||||
Protocol string //all protocols
|
||||
MessageQueue int // IRC, size of message queue for flood control
|
||||
MessageDelay int // IRC, time in millisecond to wait between messages
|
||||
MessageLength int // IRC, max length of a message allowed
|
||||
MessageFormat string // telegram
|
||||
RemoteNickFormat string // all protocols
|
||||
Server string // IRC,mattermost,XMPP,discord
|
||||
ShowJoinPart bool // all protocols
|
||||
ShowEmbeds bool // discord
|
||||
SkipTLSVerify bool // IRC, mattermost
|
||||
Team string // mattermost
|
||||
Token string // gitter, slack, discord, api
|
||||
URL string // mattermost, slack // DEPRECATED
|
||||
UseAPI bool // mattermost, slack
|
||||
UseSASL bool // IRC
|
||||
UseTLS bool // IRC
|
||||
UseFirstName bool // telegram
|
||||
UseUserName bool // discord
|
||||
UseInsecureURL bool // telegram
|
||||
WebhookBindAddress string // mattermost, slack
|
||||
WebhookURL string // mattermost, slack
|
||||
WebhookUse string // mattermost, slack, discord
|
||||
LogFile string // general
|
||||
MediaDownloadBlackList []string
|
||||
MediaDownloadPath string // Basically MediaServerUpload, but instead of uploading it, just write it to a file on the same server.
|
||||
MediaDownloadSize int // all protocols
|
||||
MediaServerDownload string
|
||||
MediaServerUpload string
|
||||
MediaConvertTgs string // telegram
|
||||
MediaConvertWebPToPNG bool // telegram
|
||||
MessageDelay int // IRC, time in millisecond to wait between messages
|
||||
MessageFormat string // telegram
|
||||
MessageLength int // IRC, max length of a message allowed
|
||||
MessageQueue int // IRC, size of message queue for flood control
|
||||
MessageSplit bool // IRC, split long messages with newlines on MessageLength instead of clipping
|
||||
Muc string // xmpp
|
||||
Name string // all protocols
|
||||
Nick string // all protocols
|
||||
NickFormatter string // mattermost, slack
|
||||
NickServNick string // IRC
|
||||
NickServUsername string // IRC
|
||||
NickServPassword string // IRC
|
||||
NicksPerRow int // mattermost, slack
|
||||
NoHomeServerSuffix bool // matrix
|
||||
NoSendJoinPart bool // all protocols
|
||||
NoTLS bool // mattermost, xmpp
|
||||
Password string // IRC,mattermost,XMPP,matrix
|
||||
PrefixMessagesWithNick bool // mattemost, slack
|
||||
PreserveThreading bool // slack
|
||||
Protocol string // all protocols
|
||||
QuoteDisable bool // telegram
|
||||
QuoteFormat string // telegram
|
||||
QuoteLengthLimit int // telegram
|
||||
RejoinDelay int // IRC
|
||||
ReplaceMessages [][]string // all protocols
|
||||
ReplaceNicks [][]string // all protocols
|
||||
RemoteNickFormat string // all protocols
|
||||
RunCommands []string // IRC
|
||||
Server string // IRC,mattermost,XMPP,discord
|
||||
SessionFile string // msteams,whatsapp
|
||||
ShowJoinPart bool // all protocols
|
||||
ShowTopicChange bool // slack
|
||||
ShowUserTyping bool // slack
|
||||
ShowEmbeds bool // discord
|
||||
SkipTLSVerify bool // IRC, mattermost
|
||||
SkipVersionCheck bool // mattermost
|
||||
StripNick bool // all protocols
|
||||
StripMarkdown bool // irc
|
||||
SyncTopic bool // slack
|
||||
TengoModifyMessage string // general
|
||||
Team string // mattermost, keybase
|
||||
TeamID string // msteams
|
||||
TenantID string // msteams
|
||||
Token string // gitter, slack, discord, api
|
||||
Topic string // zulip
|
||||
URL string // mattermost, slack // DEPRECATED
|
||||
UseAPI bool // mattermost, slack
|
||||
UseLocalAvatar []string // discord
|
||||
UseSASL bool // IRC
|
||||
UseTLS bool // IRC
|
||||
UseDiscriminator bool // discord
|
||||
UseFirstName bool // telegram
|
||||
UseUserName bool // discord, matrix
|
||||
UseInsecureURL bool // telegram
|
||||
VerboseJoinPart bool // IRC
|
||||
WebhookBindAddress string // mattermost, slack
|
||||
WebhookURL string // mattermost, slack
|
||||
}
|
||||
|
||||
type ChannelOptions struct {
|
||||
Key string // irc
|
||||
Key string // irc, xmpp
|
||||
WebhookURL string // discord
|
||||
Topic string // zulip
|
||||
}
|
||||
|
||||
type Bridge struct {
|
||||
@@ -114,6 +193,13 @@ type Gateway struct {
|
||||
InOut []Bridge
|
||||
}
|
||||
|
||||
type Tengo struct {
|
||||
InMessage string
|
||||
Message string
|
||||
RemoteNickFormat string
|
||||
OutMessage string
|
||||
}
|
||||
|
||||
type SameChannelGateway struct {
|
||||
Name string
|
||||
Enable bool
|
||||
@@ -121,92 +207,177 @@ type SameChannelGateway struct {
|
||||
Accounts []string
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Api map[string]Protocol
|
||||
type BridgeValues struct {
|
||||
API map[string]Protocol
|
||||
IRC map[string]Protocol
|
||||
Mattermost map[string]Protocol
|
||||
Matrix map[string]Protocol
|
||||
Slack map[string]Protocol
|
||||
SlackLegacy map[string]Protocol
|
||||
Steam map[string]Protocol
|
||||
Gitter map[string]Protocol
|
||||
Xmpp map[string]Protocol
|
||||
XMPP map[string]Protocol
|
||||
Discord map[string]Protocol
|
||||
Telegram map[string]Protocol
|
||||
Rocketchat map[string]Protocol
|
||||
SSHChat map[string]Protocol
|
||||
WhatsApp map[string]Protocol // TODO is this struct used? Search for "SlackLegacy" for example didn't return any results
|
||||
Zulip map[string]Protocol
|
||||
Keybase map[string]Protocol
|
||||
Mumble map[string]Protocol
|
||||
General Protocol
|
||||
Tengo Tengo
|
||||
Gateway []Gateway
|
||||
SameChannelGateway []SameChannelGateway
|
||||
}
|
||||
|
||||
func NewConfig(cfgfile string) *Config {
|
||||
var cfg Config
|
||||
if _, err := toml.DecodeFile(cfgfile, &cfg); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fail := false
|
||||
for k, v := range cfg.Mattermost {
|
||||
res := Deprecated(v, "mattermost."+k)
|
||||
if res {
|
||||
fail = res
|
||||
}
|
||||
}
|
||||
for k, v := range cfg.Slack {
|
||||
res := Deprecated(v, "slack."+k)
|
||||
if res {
|
||||
fail = res
|
||||
}
|
||||
}
|
||||
for k, v := range cfg.Rocketchat {
|
||||
res := Deprecated(v, "rocketchat."+k)
|
||||
if res {
|
||||
fail = res
|
||||
}
|
||||
}
|
||||
if fail {
|
||||
log.Fatalf("Fix your config. Please see changelog for more information")
|
||||
}
|
||||
return &cfg
|
||||
type Config interface {
|
||||
Viper() *viper.Viper
|
||||
BridgeValues() *BridgeValues
|
||||
IsKeySet(key string) bool
|
||||
GetBool(key string) (bool, bool)
|
||||
GetInt(key string) (int, bool)
|
||||
GetString(key string) (string, bool)
|
||||
GetStringSlice(key string) ([]string, bool)
|
||||
GetStringSlice2D(key string) ([][]string, bool)
|
||||
}
|
||||
|
||||
func OverrideCfgFromEnv(cfg *Config, protocol string, account string) {
|
||||
var protoCfg Protocol
|
||||
val := reflect.ValueOf(cfg).Elem()
|
||||
// loop over the Config struct
|
||||
for i := 0; i < val.NumField(); i++ {
|
||||
typeField := val.Type().Field(i)
|
||||
// look for the protocol map (both lowercase)
|
||||
if strings.ToLower(typeField.Name) == protocol {
|
||||
// get the Protocol struct from the map
|
||||
data := val.Field(i).MapIndex(reflect.ValueOf(account))
|
||||
protoCfg = data.Interface().(Protocol)
|
||||
protoStruct := reflect.ValueOf(&protoCfg).Elem()
|
||||
// loop over the found protocol struct
|
||||
for i := 0; i < protoStruct.NumField(); i++ {
|
||||
typeField := protoStruct.Type().Field(i)
|
||||
// build our environment key (eg MATTERBRIDGE_MATTERMOST_WORK_LOGIN)
|
||||
key := "matterbridge_" + protocol + "_" + account + "_" + typeField.Name
|
||||
key = strings.ToUpper(key)
|
||||
// search the environment
|
||||
res := os.Getenv(key)
|
||||
// if it exists and the current field is a string
|
||||
// then update the current field
|
||||
if res != "" {
|
||||
fieldVal := protoStruct.Field(i)
|
||||
if fieldVal.Kind() == reflect.String {
|
||||
log.Printf("config: overriding %s from env with %s\n", key, res)
|
||||
fieldVal.Set(reflect.ValueOf(res))
|
||||
}
|
||||
}
|
||||
}
|
||||
// update the map with the modified Protocol (cfg.Protocol[account] = Protocol)
|
||||
val.Field(i).SetMapIndex(reflect.ValueOf(account), reflect.ValueOf(protoCfg))
|
||||
break
|
||||
type config struct {
|
||||
sync.RWMutex
|
||||
|
||||
logger *logrus.Entry
|
||||
v *viper.Viper
|
||||
cv *BridgeValues
|
||||
}
|
||||
|
||||
// NewConfig instantiates a new configuration based on the specified configuration file path.
|
||||
func NewConfig(rootLogger *logrus.Logger, cfgfile string) Config {
|
||||
logger := rootLogger.WithFields(logrus.Fields{"prefix": "config"})
|
||||
|
||||
viper.SetConfigFile(cfgfile)
|
||||
input, err := ioutil.ReadFile(cfgfile)
|
||||
if err != nil {
|
||||
logger.Fatalf("Failed to read configuration file: %#v", err)
|
||||
}
|
||||
|
||||
cfgtype := detectConfigType(cfgfile)
|
||||
mycfg := newConfigFromString(logger, input, cfgtype)
|
||||
if mycfg.cv.General.LogFile != "" {
|
||||
logfile, err := os.OpenFile(mycfg.cv.General.LogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
|
||||
if err == nil {
|
||||
logger.Info("Opening log file ", mycfg.cv.General.LogFile)
|
||||
rootLogger.Out = logfile
|
||||
} else {
|
||||
logger.Warn("Failed to open ", mycfg.cv.General.LogFile)
|
||||
}
|
||||
}
|
||||
if mycfg.cv.General.MediaDownloadSize == 0 {
|
||||
mycfg.cv.General.MediaDownloadSize = 1000000
|
||||
}
|
||||
viper.WatchConfig()
|
||||
viper.OnConfigChange(func(e fsnotify.Event) {
|
||||
logger.Println("Config file changed:", e.Name)
|
||||
})
|
||||
return mycfg
|
||||
}
|
||||
|
||||
// detectConfigType detects JSON and YAML formats, defaults to TOML.
|
||||
func detectConfigType(cfgfile string) string {
|
||||
fileExt := filepath.Ext(cfgfile)
|
||||
switch fileExt {
|
||||
case ".json":
|
||||
return "json"
|
||||
case ".yaml", ".yml":
|
||||
return "yaml"
|
||||
}
|
||||
return "toml"
|
||||
}
|
||||
|
||||
// NewConfigFromString instantiates a new configuration based on the specified string.
|
||||
func NewConfigFromString(rootLogger *logrus.Logger, input []byte) Config {
|
||||
logger := rootLogger.WithFields(logrus.Fields{"prefix": "config"})
|
||||
return newConfigFromString(logger, input, "toml")
|
||||
}
|
||||
|
||||
func newConfigFromString(logger *logrus.Entry, input []byte, cfgtype string) *config {
|
||||
viper.SetConfigType(cfgtype)
|
||||
viper.SetEnvPrefix("matterbridge")
|
||||
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_", "-", "_"))
|
||||
viper.AutomaticEnv()
|
||||
|
||||
if err := viper.ReadConfig(bytes.NewBuffer(input)); err != nil {
|
||||
logger.Fatalf("Failed to parse the configuration: %s", err)
|
||||
}
|
||||
|
||||
cfg := &BridgeValues{}
|
||||
if err := viper.Unmarshal(cfg); err != nil {
|
||||
logger.Fatalf("Failed to load the configuration: %s", err)
|
||||
}
|
||||
return &config{
|
||||
logger: logger,
|
||||
v: viper.GetViper(),
|
||||
cv: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func GetIconURL(msg *Message, cfg *Protocol) string {
|
||||
iconURL := cfg.IconURL
|
||||
func (c *config) BridgeValues() *BridgeValues {
|
||||
return c.cv
|
||||
}
|
||||
|
||||
func (c *config) Viper() *viper.Viper {
|
||||
return c.v
|
||||
}
|
||||
|
||||
func (c *config) IsKeySet(key string) bool {
|
||||
c.RLock()
|
||||
defer c.RUnlock()
|
||||
return c.v.IsSet(key)
|
||||
}
|
||||
|
||||
func (c *config) GetBool(key string) (bool, bool) {
|
||||
c.RLock()
|
||||
defer c.RUnlock()
|
||||
return c.v.GetBool(key), c.v.IsSet(key)
|
||||
}
|
||||
|
||||
func (c *config) GetInt(key string) (int, bool) {
|
||||
c.RLock()
|
||||
defer c.RUnlock()
|
||||
return c.v.GetInt(key), c.v.IsSet(key)
|
||||
}
|
||||
|
||||
func (c *config) GetString(key string) (string, bool) {
|
||||
c.RLock()
|
||||
defer c.RUnlock()
|
||||
return c.v.GetString(key), c.v.IsSet(key)
|
||||
}
|
||||
|
||||
func (c *config) GetStringSlice(key string) ([]string, bool) {
|
||||
c.RLock()
|
||||
defer c.RUnlock()
|
||||
return c.v.GetStringSlice(key), c.v.IsSet(key)
|
||||
}
|
||||
|
||||
func (c *config) GetStringSlice2D(key string) ([][]string, bool) {
|
||||
c.RLock()
|
||||
defer c.RUnlock()
|
||||
|
||||
res, ok := c.v.Get(key).([]interface{})
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
var result [][]string
|
||||
for _, entry := range res {
|
||||
result2 := []string{}
|
||||
for _, entry2 := range entry.([]interface{}) {
|
||||
result2 = append(result2, entry2.(string))
|
||||
}
|
||||
result = append(result, result2)
|
||||
}
|
||||
return result, true
|
||||
}
|
||||
|
||||
func GetIconURL(msg *Message, iconURL string) string {
|
||||
info := strings.Split(msg.Account, ".")
|
||||
protocol := info[0]
|
||||
name := info[1]
|
||||
@@ -216,16 +387,49 @@ func GetIconURL(msg *Message, cfg *Protocol) string {
|
||||
return iconURL
|
||||
}
|
||||
|
||||
func Deprecated(cfg Protocol, account string) bool {
|
||||
if cfg.BindAddress != "" {
|
||||
log.Printf("ERROR: %s BindAddress is deprecated, you need to change it to WebhookBindAddress.", account)
|
||||
} else if cfg.URL != "" {
|
||||
log.Printf("ERROR: %s URL is deprecated, you need to change it to WebhookURL.", account)
|
||||
} else if cfg.UseAPI {
|
||||
log.Printf("ERROR: %s UseAPI is deprecated, it's enabled by default, please remove it from your config file.", account)
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
//log.Fatalf("ERROR: Fix your config: %s", account)
|
||||
type TestConfig struct {
|
||||
Config
|
||||
|
||||
Overrides map[string]interface{}
|
||||
}
|
||||
|
||||
func (c *TestConfig) IsKeySet(key string) bool {
|
||||
_, ok := c.Overrides[key]
|
||||
return ok || c.Config.IsKeySet(key)
|
||||
}
|
||||
|
||||
func (c *TestConfig) GetBool(key string) (bool, bool) {
|
||||
val, ok := c.Overrides[key]
|
||||
if ok {
|
||||
return val.(bool), true
|
||||
}
|
||||
return c.Config.GetBool(key)
|
||||
}
|
||||
|
||||
func (c *TestConfig) GetInt(key string) (int, bool) {
|
||||
if val, ok := c.Overrides[key]; ok {
|
||||
return val.(int), true
|
||||
}
|
||||
return c.Config.GetInt(key)
|
||||
}
|
||||
|
||||
func (c *TestConfig) GetString(key string) (string, bool) {
|
||||
if val, ok := c.Overrides[key]; ok {
|
||||
return val.(string), true
|
||||
}
|
||||
return c.Config.GetString(key)
|
||||
}
|
||||
|
||||
func (c *TestConfig) GetStringSlice(key string) ([]string, bool) {
|
||||
if val, ok := c.Overrides[key]; ok {
|
||||
return val.([]string), true
|
||||
}
|
||||
return c.Config.GetStringSlice(key)
|
||||
}
|
||||
|
||||
func (c *TestConfig) GetStringSlice2D(key string) ([][]string, bool) {
|
||||
if val, ok := c.Overrides[key]; ok {
|
||||
return val.([][]string), true
|
||||
}
|
||||
return c.Config.GetStringSlice2D(key)
|
||||
}
|
||||
|
||||
@@ -1,387 +1,342 @@
|
||||
package bdiscord
|
||||
|
||||
import (
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
log "github.com/Sirupsen/logrus"
|
||||
"github.com/bwmarrin/discordgo"
|
||||
"regexp"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/42wim/matterbridge/bridge"
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
"github.com/42wim/matterbridge/bridge/discord/transmitter"
|
||||
"github.com/42wim/matterbridge/bridge/helper"
|
||||
"github.com/matterbridge/discordgo"
|
||||
)
|
||||
|
||||
type bdiscord struct {
|
||||
c *discordgo.Session
|
||||
Config *config.Protocol
|
||||
Remote chan config.Message
|
||||
Account string
|
||||
Channels []*discordgo.Channel
|
||||
Nick string
|
||||
UseChannelID bool
|
||||
userMemberMap map[string]*discordgo.Member
|
||||
guildID string
|
||||
webhookID string
|
||||
webhookToken string
|
||||
const MessageLength = 1950
|
||||
|
||||
type Bdiscord struct {
|
||||
*bridge.Config
|
||||
|
||||
c *discordgo.Session
|
||||
|
||||
nick string
|
||||
userID string
|
||||
guildID string
|
||||
|
||||
channelsMutex sync.RWMutex
|
||||
channels []*discordgo.Channel
|
||||
channelInfoMap map[string]*config.ChannelInfo
|
||||
sync.RWMutex
|
||||
|
||||
membersMutex sync.RWMutex
|
||||
userMemberMap map[string]*discordgo.Member
|
||||
nickMemberMap map[string]*discordgo.Member
|
||||
|
||||
// Webhook specific logic
|
||||
useAutoWebhooks bool
|
||||
transmitter *transmitter.Transmitter
|
||||
}
|
||||
|
||||
var flog *log.Entry
|
||||
var protocol = "discord"
|
||||
|
||||
func init() {
|
||||
flog = log.WithFields(log.Fields{"module": protocol})
|
||||
}
|
||||
|
||||
func New(cfg config.Protocol, account string, c chan config.Message) *bdiscord {
|
||||
b := &bdiscord{}
|
||||
b.Config = &cfg
|
||||
b.Remote = c
|
||||
b.Account = account
|
||||
func New(cfg *bridge.Config) bridge.Bridger {
|
||||
b := &Bdiscord{Config: cfg}
|
||||
b.userMemberMap = make(map[string]*discordgo.Member)
|
||||
b.nickMemberMap = make(map[string]*discordgo.Member)
|
||||
b.channelInfoMap = make(map[string]*config.ChannelInfo)
|
||||
if b.Config.WebhookURL != "" {
|
||||
flog.Debug("Configuring Discord Incoming Webhook")
|
||||
b.webhookID, b.webhookToken = b.splitURL(b.Config.WebhookURL)
|
||||
|
||||
b.useAutoWebhooks = b.GetBool("AutoWebhooks")
|
||||
if b.useAutoWebhooks {
|
||||
b.Log.Debug("Using automatic webhooks")
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *bdiscord) Connect() error {
|
||||
func (b *Bdiscord) Connect() error {
|
||||
var err error
|
||||
flog.Info("Connecting")
|
||||
if b.Config.WebhookURL == "" {
|
||||
flog.Info("Connecting using token")
|
||||
} else {
|
||||
flog.Info("Connecting using webhookurl (for posting) and token")
|
||||
token := b.GetString("Token")
|
||||
b.Log.Info("Connecting")
|
||||
if !strings.HasPrefix(b.GetString("Token"), "Bot ") {
|
||||
token = "Bot " + b.GetString("Token")
|
||||
}
|
||||
if !strings.HasPrefix(b.Config.Token, "Bot ") {
|
||||
b.Config.Token = "Bot " + b.Config.Token
|
||||
// if we have a User token, remove the `Bot` prefix
|
||||
if strings.HasPrefix(b.GetString("Token"), "User ") {
|
||||
token = strings.Replace(b.GetString("Token"), "User ", "", -1)
|
||||
}
|
||||
b.c, err = discordgo.New(b.Config.Token)
|
||||
|
||||
b.c, err = discordgo.New(token)
|
||||
if err != nil {
|
||||
flog.Debugf("%#v", err)
|
||||
return err
|
||||
}
|
||||
flog.Info("Connection succeeded")
|
||||
b.Log.Info("Connection succeeded")
|
||||
b.c.AddHandler(b.messageCreate)
|
||||
b.c.AddHandler(b.messageTyping)
|
||||
b.c.AddHandler(b.memberUpdate)
|
||||
b.c.AddHandler(b.messageUpdate)
|
||||
b.c.AddHandler(b.messageDelete)
|
||||
b.c.AddHandler(b.messageDeleteBulk)
|
||||
b.c.AddHandler(b.memberAdd)
|
||||
b.c.AddHandler(b.memberRemove)
|
||||
err = b.c.Open()
|
||||
if err != nil {
|
||||
flog.Debugf("%#v", err)
|
||||
return err
|
||||
}
|
||||
guilds, err := b.c.UserGuilds(100, "", "")
|
||||
if err != nil {
|
||||
flog.Debugf("%#v", err)
|
||||
return err
|
||||
}
|
||||
userinfo, err := b.c.User("@me")
|
||||
if err != nil {
|
||||
flog.Debugf("%#v", err)
|
||||
return err
|
||||
}
|
||||
b.Nick = userinfo.Username
|
||||
serverName := strings.Replace(b.GetString("Server"), "ID:", "", -1)
|
||||
b.nick = userinfo.Username
|
||||
b.userID = userinfo.ID
|
||||
|
||||
// Try and find this account's guild, and populate channels
|
||||
b.channelsMutex.Lock()
|
||||
for _, guild := range guilds {
|
||||
if guild.Name == b.Config.Server {
|
||||
b.Channels, err = b.c.GuildChannels(guild.ID)
|
||||
b.guildID = guild.ID
|
||||
if err != nil {
|
||||
flog.Debugf("%#v", err)
|
||||
return err
|
||||
// Skip, if the server name does not match the visible name or the ID
|
||||
if guild.Name != serverName && guild.ID != serverName {
|
||||
continue
|
||||
}
|
||||
|
||||
// Complain about an ambiguous Server setting. Two Discord servers could have the same title!
|
||||
// For IDs, practically this will never happen. It would only trigger if some server's name is also an ID.
|
||||
if b.guildID != "" {
|
||||
return fmt.Errorf("found multiple Discord servers with the same name %#v, expected to see only one", serverName)
|
||||
}
|
||||
|
||||
// Getting this guild's channel could result in a permission error
|
||||
b.channels, err = b.c.GuildChannels(guild.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not get %#v's channels: %w", b.GetString("Server"), err)
|
||||
}
|
||||
|
||||
b.guildID = guild.ID
|
||||
}
|
||||
b.channelsMutex.Unlock()
|
||||
|
||||
// If we couldn't find a guild, we print extra debug information and return a nice error
|
||||
if b.guildID == "" {
|
||||
err = fmt.Errorf("could not find Discord server %#v", b.GetString("Server"))
|
||||
b.Log.Error(err.Error())
|
||||
|
||||
// Print all of the possible server values
|
||||
b.Log.Info("Possible server values:")
|
||||
for _, guild := range guilds {
|
||||
b.Log.Infof("\t- Server=%#v # by name", guild.Name)
|
||||
b.Log.Infof("\t- Server=%#v # by ID", guild.ID)
|
||||
}
|
||||
|
||||
// If there are no results, we should say that
|
||||
if len(guilds) == 0 {
|
||||
b.Log.Info("\t- (none found)")
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Legacy note: WebhookURL used to have an actual webhook URL that we would edit,
|
||||
// but we stopped doing that due to Discord making rate limits more aggressive.
|
||||
//
|
||||
// Even older: the same WebhookURL used to be used by every channel, which is usually unexpected.
|
||||
// This is no longer possible.
|
||||
if b.GetString("WebhookURL") != "" {
|
||||
message := "The global WebhookURL setting has been removed. "
|
||||
message += "You can get similar \"webhook editing\" behaviour by replacing this line with `AutoWebhooks=true`. "
|
||||
message += "If you rely on the old-OLD (non-editing) behaviour, can move the WebhookURL to specific channel sections."
|
||||
b.Log.Errorln(message)
|
||||
return fmt.Errorf("use of removed WebhookURL setting")
|
||||
}
|
||||
|
||||
if b.GetInt("debuglevel") > 0 {
|
||||
b.Log.Debug("enabling even more discord debug")
|
||||
b.c.Debug = true
|
||||
}
|
||||
|
||||
// Initialise webhook management
|
||||
b.transmitter = transmitter.New(b.c, b.guildID, "matterbridge", b.useAutoWebhooks)
|
||||
b.transmitter.Log = b.Log
|
||||
|
||||
var webhookChannelIDs []string
|
||||
for _, channel := range b.Channels {
|
||||
channelID := b.getChannelID(channel.Name) // note(qaisjp): this readlocks channelsMutex
|
||||
|
||||
// If a WebhookURL was not explicitly provided for this channel,
|
||||
// there are two options: just a regular bot message (ugly) or this is should be webhook sent
|
||||
if channel.Options.WebhookURL == "" {
|
||||
// If it should be webhook sent, we should enforce this via the transmitter
|
||||
if b.useAutoWebhooks {
|
||||
webhookChannelIDs = append(webhookChannelIDs, channelID)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
whID, whToken, ok := b.splitURL(channel.Options.WebhookURL)
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to parse WebhookURL %#v for channel %#v", channel.Options.WebhookURL, channel.ID)
|
||||
}
|
||||
|
||||
b.transmitter.AddWebhook(channelID, &discordgo.Webhook{
|
||||
ID: whID,
|
||||
Token: whToken,
|
||||
GuildID: b.guildID,
|
||||
ChannelID: channelID,
|
||||
})
|
||||
}
|
||||
|
||||
if b.useAutoWebhooks {
|
||||
err = b.transmitter.RefreshGuildWebhooks(webhookChannelIDs)
|
||||
if err != nil {
|
||||
b.Log.WithError(err).Println("transmitter could not refresh guild webhooks")
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Obtaining guild members and initializing nickname mapping.
|
||||
b.membersMutex.Lock()
|
||||
defer b.membersMutex.Unlock()
|
||||
members, err := b.c.GuildMembers(b.guildID, "", 1000)
|
||||
if err != nil {
|
||||
b.Log.Error("Error obtaining server members: ", err)
|
||||
return err
|
||||
}
|
||||
for _, member := range members {
|
||||
if member == nil {
|
||||
b.Log.Warnf("Skipping missing information for a user.")
|
||||
continue
|
||||
}
|
||||
b.userMemberMap[member.User.ID] = member
|
||||
b.nickMemberMap[member.User.Username] = member
|
||||
if member.Nick != "" {
|
||||
b.nickMemberMap[member.Nick] = member
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *bdiscord) Disconnect() error {
|
||||
return nil
|
||||
func (b *Bdiscord) Disconnect() error {
|
||||
return b.c.Close()
|
||||
}
|
||||
|
||||
func (b *bdiscord) JoinChannel(channel config.ChannelInfo) error {
|
||||
func (b *Bdiscord) JoinChannel(channel config.ChannelInfo) error {
|
||||
b.channelsMutex.Lock()
|
||||
defer b.channelsMutex.Unlock()
|
||||
|
||||
b.channelInfoMap[channel.ID] = &channel
|
||||
idcheck := strings.Split(channel.Name, "ID:")
|
||||
if len(idcheck) > 1 {
|
||||
b.UseChannelID = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *bdiscord) Send(msg config.Message) (string, error) {
|
||||
flog.Debugf("Receiving %#v", msg)
|
||||
func (b *Bdiscord) Send(msg config.Message) (string, error) {
|
||||
b.Log.Debugf("=> Receiving %#v", msg)
|
||||
|
||||
channelID := b.getChannelID(msg.Channel)
|
||||
if channelID == "" {
|
||||
flog.Errorf("Could not find channelID for %v", msg.Channel)
|
||||
return "", fmt.Errorf("Could not find channelID for %v", msg.Channel)
|
||||
}
|
||||
|
||||
if msg.Event == config.EventUserTyping {
|
||||
if b.GetBool("ShowUserTyping") {
|
||||
err := b.c.ChannelTyping(channelID)
|
||||
return "", err
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
if msg.Event == config.EVENT_USER_ACTION {
|
||||
|
||||
// Make a action /me of the message
|
||||
if msg.Event == config.EventUserAction {
|
||||
msg.Text = "_" + msg.Text + "_"
|
||||
}
|
||||
|
||||
wID := b.webhookID
|
||||
wToken := b.webhookToken
|
||||
if ci, ok := b.channelInfoMap[msg.Channel+b.Account]; ok {
|
||||
if ci.Options.WebhookURL != "" {
|
||||
wID, wToken = b.splitURL(ci.Options.WebhookURL)
|
||||
// Handle prefix hint for unthreaded messages.
|
||||
if msg.ParentNotFound() {
|
||||
msg.ParentID = ""
|
||||
msg.Text = fmt.Sprintf("[thread]: %s", msg.Text)
|
||||
}
|
||||
|
||||
// Use webhook to send the message
|
||||
useWebhooks := b.shouldMessageUseWebhooks(&msg)
|
||||
if useWebhooks && msg.Event != config.EventMsgDelete && msg.ParentID == "" {
|
||||
return b.handleEventWebhook(&msg, channelID)
|
||||
}
|
||||
|
||||
return b.handleEventBotUser(&msg, channelID)
|
||||
}
|
||||
|
||||
// handleEventDirect handles events via the bot user
|
||||
func (b *Bdiscord) handleEventBotUser(msg *config.Message, channelID string) (string, error) {
|
||||
b.Log.Debugf("Broadcasting using token (API)")
|
||||
|
||||
// Delete message
|
||||
if msg.Event == config.EventMsgDelete {
|
||||
if msg.ID == "" {
|
||||
return "", nil
|
||||
}
|
||||
err := b.c.ChannelMessageDelete(channelID, msg.ID)
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Upload a file if it exists
|
||||
if msg.Extra != nil {
|
||||
for _, rmsg := range helper.HandleExtra(msg, b.General) {
|
||||
rmsg.Text = helper.ClipMessage(rmsg.Text, MessageLength)
|
||||
if _, err := b.c.ChannelMessageSend(channelID, rmsg.Username+rmsg.Text); err != nil {
|
||||
b.Log.Errorf("Could not send message %#v: %s", rmsg, err)
|
||||
}
|
||||
}
|
||||
// check if we have files to upload (from slack, telegram or mattermost)
|
||||
if len(msg.Extra["file"]) > 0 {
|
||||
return b.handleUploadFile(msg, channelID)
|
||||
}
|
||||
}
|
||||
|
||||
if wID == "" {
|
||||
flog.Debugf("Broadcasting using token (API)")
|
||||
if msg.Event == config.EVENT_MSG_DELETE {
|
||||
if msg.ID == "" {
|
||||
return "", nil
|
||||
}
|
||||
err := b.c.ChannelMessageDelete(channelID, msg.ID)
|
||||
return "", err
|
||||
msg.Text = helper.ClipMessage(msg.Text, MessageLength)
|
||||
msg.Text = b.replaceUserMentions(msg.Text)
|
||||
|
||||
// Edit message
|
||||
if msg.ID != "" {
|
||||
_, err := b.c.ChannelMessageEdit(channelID, msg.ID, msg.Username+msg.Text)
|
||||
return msg.ID, err
|
||||
}
|
||||
|
||||
m := discordgo.MessageSend{
|
||||
Content: msg.Username + msg.Text,
|
||||
}
|
||||
|
||||
if msg.ParentValid() {
|
||||
m.Reference = &discordgo.MessageReference{
|
||||
MessageID: msg.ParentID,
|
||||
ChannelID: channelID,
|
||||
GuildID: b.guildID,
|
||||
}
|
||||
if msg.ID != "" {
|
||||
_, err := b.c.ChannelMessageEdit(channelID, msg.ID, msg.Username+msg.Text)
|
||||
return msg.ID, err
|
||||
}
|
||||
|
||||
// Post normal message
|
||||
res, err := b.c.ChannelMessageSendComplex(channelID, &m)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return res.ID, nil
|
||||
}
|
||||
|
||||
// handleUploadFile handles native upload of files
|
||||
func (b *Bdiscord) handleUploadFile(msg *config.Message, channelID string) (string, error) {
|
||||
var err error
|
||||
for _, f := range msg.Extra["file"] {
|
||||
fi := f.(config.FileInfo)
|
||||
file := discordgo.File{
|
||||
Name: fi.Name,
|
||||
ContentType: "",
|
||||
Reader: bytes.NewReader(*fi.Data),
|
||||
}
|
||||
res, err := b.c.ChannelMessageSend(channelID, msg.Username+msg.Text)
|
||||
m := discordgo.MessageSend{
|
||||
Content: msg.Username + fi.Comment,
|
||||
Files: []*discordgo.File{&file},
|
||||
}
|
||||
_, err = b.c.ChannelMessageSendComplex(channelID, &m)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return res.ID, err
|
||||
}
|
||||
flog.Debugf("Broadcasting using Webhook")
|
||||
err := b.c.WebhookExecute(
|
||||
wID,
|
||||
wToken,
|
||||
true,
|
||||
&discordgo.WebhookParams{
|
||||
Content: msg.Text,
|
||||
Username: msg.Username,
|
||||
AvatarURL: msg.Avatar,
|
||||
})
|
||||
return "", err
|
||||
}
|
||||
|
||||
func (b *bdiscord) messageDelete(s *discordgo.Session, m *discordgo.MessageDelete) {
|
||||
rmsg := config.Message{Account: b.Account, ID: m.ID, Event: config.EVENT_MSG_DELETE, Text: config.EVENT_MSG_DELETE}
|
||||
rmsg.Channel = b.getChannelName(m.ChannelID)
|
||||
if b.UseChannelID {
|
||||
rmsg.Channel = "ID:" + m.ChannelID
|
||||
}
|
||||
flog.Debugf("Sending message from %s to gateway", b.Account)
|
||||
flog.Debugf("Message is %#v", rmsg)
|
||||
b.Remote <- rmsg
|
||||
}
|
||||
|
||||
func (b *bdiscord) messageUpdate(s *discordgo.Session, m *discordgo.MessageUpdate) {
|
||||
if b.Config.EditDisable {
|
||||
return
|
||||
}
|
||||
// only when message is actually edited
|
||||
if m.Message.EditedTimestamp != "" {
|
||||
flog.Debugf("Sending edit message")
|
||||
m.Content = m.Content + b.Config.EditSuffix
|
||||
b.messageCreate(s, (*discordgo.MessageCreate)(m))
|
||||
}
|
||||
}
|
||||
|
||||
func (b *bdiscord) messageCreate(s *discordgo.Session, m *discordgo.MessageCreate) {
|
||||
// not relay our own messages
|
||||
if m.Author.Username == b.Nick {
|
||||
return
|
||||
}
|
||||
// if using webhooks, do not relay if it's ours
|
||||
if b.useWebhook() && m.Author.Bot && b.isWebhookID(m.Author.ID) {
|
||||
return
|
||||
}
|
||||
|
||||
if len(m.Attachments) > 0 {
|
||||
for _, attach := range m.Attachments {
|
||||
m.Content = m.Content + "\n" + attach.URL
|
||||
return "", fmt.Errorf("file upload failed: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
var text string
|
||||
if m.Content != "" {
|
||||
flog.Debugf("Receiving message %#v", m.Message)
|
||||
if len(m.MentionRoles) > 0 {
|
||||
m.Message.Content = b.replaceRoleMentions(m.Message.Content)
|
||||
}
|
||||
m.Message.Content = b.stripCustomoji(m.Message.Content)
|
||||
m.Message.Content = b.replaceChannelMentions(m.Message.Content)
|
||||
text = m.ContentWithMentionsReplaced()
|
||||
}
|
||||
|
||||
rmsg := config.Message{Account: b.Account, Avatar: "https://cdn.discordapp.com/avatars/" + m.Author.ID + "/" + m.Author.Avatar + ".jpg",
|
||||
UserID: m.Author.ID, ID: m.ID}
|
||||
|
||||
rmsg.Channel = b.getChannelName(m.ChannelID)
|
||||
if b.UseChannelID {
|
||||
rmsg.Channel = "ID:" + m.ChannelID
|
||||
}
|
||||
|
||||
if !b.Config.UseUserName {
|
||||
rmsg.Username = b.getNick(m.Author)
|
||||
} else {
|
||||
rmsg.Username = m.Author.Username
|
||||
}
|
||||
|
||||
if b.Config.ShowEmbeds && m.Message.Embeds != nil {
|
||||
for _, embed := range m.Message.Embeds {
|
||||
text = text + "embed: " + embed.Title + " - " + embed.Description + " - " + embed.URL + "\n"
|
||||
}
|
||||
}
|
||||
|
||||
// no empty messages
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
|
||||
text, ok := b.replaceAction(text)
|
||||
if ok {
|
||||
rmsg.Event = config.EVENT_USER_ACTION
|
||||
}
|
||||
|
||||
rmsg.Text = text
|
||||
flog.Debugf("Sending message from %s on %s to gateway", m.Author.Username, b.Account)
|
||||
flog.Debugf("Message is %#v", rmsg)
|
||||
b.Remote <- rmsg
|
||||
}
|
||||
|
||||
func (b *bdiscord) memberUpdate(s *discordgo.Session, m *discordgo.GuildMemberUpdate) {
|
||||
b.Lock()
|
||||
if _, ok := b.userMemberMap[m.Member.User.ID]; ok {
|
||||
flog.Debugf("%s: memberupdate: user %s (nick %s) changes nick to %s", b.Account, m.Member.User.Username, b.userMemberMap[m.Member.User.ID].Nick, m.Member.Nick)
|
||||
}
|
||||
b.userMemberMap[m.Member.User.ID] = m.Member
|
||||
b.Unlock()
|
||||
}
|
||||
|
||||
func (b *bdiscord) getNick(user *discordgo.User) string {
|
||||
var err error
|
||||
b.Lock()
|
||||
defer b.Unlock()
|
||||
if _, ok := b.userMemberMap[user.ID]; ok {
|
||||
if b.userMemberMap[user.ID] != nil {
|
||||
if b.userMemberMap[user.ID].Nick != "" {
|
||||
// only return if nick is set
|
||||
return b.userMemberMap[user.ID].Nick
|
||||
}
|
||||
// otherwise return username
|
||||
return user.Username
|
||||
}
|
||||
}
|
||||
// if we didn't find nick, search for it
|
||||
member, err := b.c.GuildMember(b.guildID, user.ID)
|
||||
if err != nil {
|
||||
return user.Username
|
||||
}
|
||||
b.userMemberMap[user.ID] = member
|
||||
// only return if nick is set
|
||||
if b.userMemberMap[user.ID].Nick != "" {
|
||||
return b.userMemberMap[user.ID].Nick
|
||||
}
|
||||
return user.Username
|
||||
}
|
||||
|
||||
func (b *bdiscord) getChannelID(name string) string {
|
||||
idcheck := strings.Split(name, "ID:")
|
||||
if len(idcheck) > 1 {
|
||||
return idcheck[1]
|
||||
}
|
||||
for _, channel := range b.Channels {
|
||||
if channel.Name == name {
|
||||
return channel.ID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (b *bdiscord) getChannelName(id string) string {
|
||||
for _, channel := range b.Channels {
|
||||
if channel.ID == id {
|
||||
return channel.Name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (b *bdiscord) replaceRoleMentions(text string) string {
|
||||
roles, err := b.c.GuildRoles(b.guildID)
|
||||
if err != nil {
|
||||
flog.Debugf("%#v", string(err.(*discordgo.RESTError).ResponseBody))
|
||||
return text
|
||||
}
|
||||
for _, role := range roles {
|
||||
text = strings.Replace(text, "<@&"+role.ID+">", "@"+role.Name, -1)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func (b *bdiscord) replaceChannelMentions(text string) string {
|
||||
var err error
|
||||
re := regexp.MustCompile("<#[0-9]+>")
|
||||
text = re.ReplaceAllStringFunc(text, func(m string) string {
|
||||
channel := b.getChannelName(m[2 : len(m)-1])
|
||||
// if at first don't succeed, try again
|
||||
if channel == "" {
|
||||
b.Channels, err = b.c.GuildChannels(b.guildID)
|
||||
if err != nil {
|
||||
return "#unknownchannel"
|
||||
}
|
||||
channel = b.getChannelName(m[2 : len(m)-1])
|
||||
return "#" + channel
|
||||
}
|
||||
return "#" + channel
|
||||
})
|
||||
return text
|
||||
}
|
||||
|
||||
func (b *bdiscord) replaceAction(text string) (string, bool) {
|
||||
if strings.HasPrefix(text, "_") && strings.HasSuffix(text, "_") {
|
||||
return strings.Replace(text, "_", "", -1), true
|
||||
}
|
||||
return text, false
|
||||
}
|
||||
|
||||
func (b *bdiscord) stripCustomoji(text string) string {
|
||||
// <:doge:302803592035958784>
|
||||
re := regexp.MustCompile("<(:.*?:)[0-9]+>")
|
||||
return re.ReplaceAllString(text, `$1`)
|
||||
}
|
||||
|
||||
// splitURL splits a webhookURL and returns the id and token
|
||||
func (b *bdiscord) splitURL(url string) (string, string) {
|
||||
webhookURLSplit := strings.Split(url, "/")
|
||||
return webhookURLSplit[len(webhookURLSplit)-2], webhookURLSplit[len(webhookURLSplit)-1]
|
||||
}
|
||||
|
||||
// useWebhook returns true if we have a webhook defined somewhere
|
||||
func (b *bdiscord) useWebhook() bool {
|
||||
if b.Config.WebhookURL != "" {
|
||||
return true
|
||||
}
|
||||
for _, channel := range b.channelInfoMap {
|
||||
if channel.Options.WebhookURL != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isWebhookID returns true if the specified id is used in a defined webhook
|
||||
func (b *bdiscord) isWebhookID(id string) bool {
|
||||
if b.Config.WebhookURL != "" {
|
||||
wID, _ := b.splitURL(b.Config.WebhookURL)
|
||||
if wID == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, channel := range b.channelInfoMap {
|
||||
if channel.Options.WebhookURL != "" {
|
||||
wID, _ := b.splitURL(channel.Options.WebhookURL)
|
||||
if wID == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
return "", nil
|
||||
}
|
||||
|
||||
237
bridge/discord/handlers.go
Normal file
237
bridge/discord/handlers.go
Normal file
@@ -0,0 +1,237 @@
|
||||
package bdiscord
|
||||
|
||||
import (
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
"github.com/matterbridge/discordgo"
|
||||
)
|
||||
|
||||
func (b *Bdiscord) messageDelete(s *discordgo.Session, m *discordgo.MessageDelete) { //nolint:unparam
|
||||
rmsg := config.Message{Account: b.Account, ID: m.ID, Event: config.EventMsgDelete, Text: config.EventMsgDelete}
|
||||
rmsg.Channel = b.getChannelName(m.ChannelID)
|
||||
|
||||
b.Log.Debugf("<= Sending message from %s to gateway", b.Account)
|
||||
b.Log.Debugf("<= Message is %#v", rmsg)
|
||||
b.Remote <- rmsg
|
||||
}
|
||||
|
||||
// TODO(qaisjp): if other bridges support bulk deletions, it could be fanned out centrally
|
||||
func (b *Bdiscord) messageDeleteBulk(s *discordgo.Session, m *discordgo.MessageDeleteBulk) { //nolint:unparam
|
||||
for _, msgID := range m.Messages {
|
||||
rmsg := config.Message{
|
||||
Account: b.Account,
|
||||
ID: msgID,
|
||||
Event: config.EventMsgDelete,
|
||||
Text: config.EventMsgDelete,
|
||||
Channel: b.getChannelName(m.ChannelID),
|
||||
}
|
||||
|
||||
b.Log.Debugf("<= Sending message from %s to gateway", b.Account)
|
||||
b.Log.Debugf("<= Message is %#v", rmsg)
|
||||
b.Remote <- rmsg
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bdiscord) messageTyping(s *discordgo.Session, m *discordgo.TypingStart) {
|
||||
if !b.GetBool("ShowUserTyping") {
|
||||
return
|
||||
}
|
||||
|
||||
// Ignore our own typing messages
|
||||
if m.UserID == b.userID {
|
||||
return
|
||||
}
|
||||
|
||||
rmsg := config.Message{Account: b.Account, Event: config.EventUserTyping}
|
||||
rmsg.Channel = b.getChannelName(m.ChannelID)
|
||||
b.Remote <- rmsg
|
||||
}
|
||||
|
||||
func (b *Bdiscord) messageUpdate(s *discordgo.Session, m *discordgo.MessageUpdate) { //nolint:unparam
|
||||
if b.GetBool("EditDisable") {
|
||||
return
|
||||
}
|
||||
// only when message is actually edited
|
||||
if m.Message.EditedTimestamp != "" {
|
||||
b.Log.Debugf("Sending edit message")
|
||||
m.Content += b.GetString("EditSuffix")
|
||||
msg := &discordgo.MessageCreate{
|
||||
Message: m.Message,
|
||||
}
|
||||
b.messageCreate(s, msg)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bdiscord) messageCreate(s *discordgo.Session, m *discordgo.MessageCreate) { //nolint:unparam
|
||||
var err error
|
||||
|
||||
// not relay our own messages
|
||||
if m.Author.Username == b.nick {
|
||||
return
|
||||
}
|
||||
// if using webhooks, do not relay if it's ours
|
||||
if m.Author.Bot && b.transmitter.HasWebhook(m.Author.ID) {
|
||||
return
|
||||
}
|
||||
|
||||
// add the url of the attachments to content
|
||||
if len(m.Attachments) > 0 {
|
||||
for _, attach := range m.Attachments {
|
||||
m.Content = m.Content + "\n" + attach.URL
|
||||
}
|
||||
}
|
||||
|
||||
rmsg := config.Message{Account: b.Account, Avatar: "https://cdn.discordapp.com/avatars/" + m.Author.ID + "/" + m.Author.Avatar + ".jpg", UserID: m.Author.ID, ID: m.ID}
|
||||
|
||||
if m.Content != "" {
|
||||
b.Log.Debugf("== Receiving event %#v", m.Message)
|
||||
m.Message.Content = b.replaceChannelMentions(m.Message.Content)
|
||||
rmsg.Text, err = m.ContentWithMoreMentionsReplaced(b.c)
|
||||
if err != nil {
|
||||
b.Log.Errorf("ContentWithMoreMentionsReplaced failed: %s", err)
|
||||
rmsg.Text = m.ContentWithMentionsReplaced()
|
||||
}
|
||||
}
|
||||
|
||||
// set channel name
|
||||
rmsg.Channel = b.getChannelName(m.ChannelID)
|
||||
|
||||
fromWebhook := m.WebhookID != ""
|
||||
if !fromWebhook && !b.GetBool("UseUserName") {
|
||||
rmsg.Username = b.getNick(m.Author, m.GuildID)
|
||||
} else {
|
||||
rmsg.Username = m.Author.Username
|
||||
if !fromWebhook && b.GetBool("UseDiscriminator") {
|
||||
rmsg.Username += "#" + m.Author.Discriminator
|
||||
}
|
||||
}
|
||||
|
||||
// if we have embedded content add it to text
|
||||
if b.GetBool("ShowEmbeds") && m.Message.Embeds != nil {
|
||||
for _, embed := range m.Message.Embeds {
|
||||
rmsg.Text += handleEmbed(embed)
|
||||
}
|
||||
}
|
||||
|
||||
// no empty messages
|
||||
if rmsg.Text == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// do we have a /me action
|
||||
var ok bool
|
||||
rmsg.Text, ok = b.replaceAction(rmsg.Text)
|
||||
if ok {
|
||||
rmsg.Event = config.EventUserAction
|
||||
}
|
||||
|
||||
// Replace emotes
|
||||
rmsg.Text = replaceEmotes(rmsg.Text)
|
||||
|
||||
// Add our parent id if it exists, and if it's not referring to a message in another channel
|
||||
if ref := m.MessageReference; ref != nil && ref.ChannelID == m.ChannelID {
|
||||
rmsg.ParentID = ref.MessageID
|
||||
}
|
||||
|
||||
b.Log.Debugf("<= Sending message from %s on %s to gateway", m.Author.Username, b.Account)
|
||||
b.Log.Debugf("<= Message is %#v", rmsg)
|
||||
b.Remote <- rmsg
|
||||
}
|
||||
|
||||
func (b *Bdiscord) memberUpdate(s *discordgo.Session, m *discordgo.GuildMemberUpdate) {
|
||||
if m.Member == nil {
|
||||
b.Log.Warnf("Received member update with no member information: %#v", m)
|
||||
}
|
||||
|
||||
b.membersMutex.Lock()
|
||||
defer b.membersMutex.Unlock()
|
||||
|
||||
if currMember, ok := b.userMemberMap[m.Member.User.ID]; ok {
|
||||
b.Log.Debugf(
|
||||
"%s: memberupdate: user %s (nick %s) changes nick to %s",
|
||||
b.Account,
|
||||
m.Member.User.Username,
|
||||
b.userMemberMap[m.Member.User.ID].Nick,
|
||||
m.Member.Nick,
|
||||
)
|
||||
delete(b.nickMemberMap, currMember.User.Username)
|
||||
delete(b.nickMemberMap, currMember.Nick)
|
||||
delete(b.userMemberMap, m.Member.User.ID)
|
||||
}
|
||||
b.userMemberMap[m.Member.User.ID] = m.Member
|
||||
b.nickMemberMap[m.Member.User.Username] = m.Member
|
||||
if m.Member.Nick != "" {
|
||||
b.nickMemberMap[m.Member.Nick] = m.Member
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bdiscord) memberAdd(s *discordgo.Session, m *discordgo.GuildMemberAdd) {
|
||||
if m.Member == nil {
|
||||
b.Log.Warnf("Received member update with no member information: %#v", m)
|
||||
return
|
||||
}
|
||||
username := m.Member.User.Username
|
||||
if m.Member.Nick != "" {
|
||||
username = m.Member.Nick
|
||||
}
|
||||
|
||||
rmsg := config.Message{
|
||||
Account: b.Account,
|
||||
Event: config.EventJoinLeave,
|
||||
Username: "system",
|
||||
Text: username + " joins",
|
||||
}
|
||||
b.Log.Debugf("<= Sending message from %s to gateway", b.Account)
|
||||
b.Log.Debugf("<= Message is %#v", rmsg)
|
||||
b.Remote <- rmsg
|
||||
}
|
||||
|
||||
func (b *Bdiscord) memberRemove(s *discordgo.Session, m *discordgo.GuildMemberRemove) {
|
||||
if m.Member == nil {
|
||||
b.Log.Warnf("Received member update with no member information: %#v", m)
|
||||
return
|
||||
}
|
||||
username := m.Member.User.Username
|
||||
if m.Member.Nick != "" {
|
||||
username = m.Member.Nick
|
||||
}
|
||||
|
||||
rmsg := config.Message{
|
||||
Account: b.Account,
|
||||
Event: config.EventJoinLeave,
|
||||
Username: "system",
|
||||
Text: username + " leaves",
|
||||
}
|
||||
b.Log.Debugf("<= Sending message from %s to gateway", b.Account)
|
||||
b.Log.Debugf("<= Message is %#v", rmsg)
|
||||
b.Remote <- rmsg
|
||||
}
|
||||
|
||||
func handleEmbed(embed *discordgo.MessageEmbed) string {
|
||||
var t []string
|
||||
var result string
|
||||
|
||||
t = append(t, embed.Title)
|
||||
t = append(t, embed.Description)
|
||||
t = append(t, embed.URL)
|
||||
|
||||
i := 0
|
||||
for _, e := range t {
|
||||
if e == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
i++
|
||||
if i == 1 {
|
||||
result += " embed: " + e
|
||||
continue
|
||||
}
|
||||
|
||||
result += " - " + e
|
||||
}
|
||||
|
||||
if result != "" {
|
||||
result += "\n"
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
58
bridge/discord/handlers_test.go
Normal file
58
bridge/discord/handlers_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package bdiscord
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/matterbridge/discordgo"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestHandleEmbed(t *testing.T) {
|
||||
testcases := map[string]struct {
|
||||
embed *discordgo.MessageEmbed
|
||||
result string
|
||||
}{
|
||||
"allempty": {
|
||||
embed: &discordgo.MessageEmbed{},
|
||||
result: "",
|
||||
},
|
||||
"one": {
|
||||
embed: &discordgo.MessageEmbed{
|
||||
Title: "blah",
|
||||
},
|
||||
result: " embed: blah\n",
|
||||
},
|
||||
"two": {
|
||||
embed: &discordgo.MessageEmbed{
|
||||
Title: "blah",
|
||||
Description: "blah2",
|
||||
},
|
||||
result: " embed: blah - blah2\n",
|
||||
},
|
||||
"three": {
|
||||
embed: &discordgo.MessageEmbed{
|
||||
Title: "blah",
|
||||
Description: "blah2",
|
||||
URL: "blah3",
|
||||
},
|
||||
result: " embed: blah - blah2 - blah3\n",
|
||||
},
|
||||
"twob": {
|
||||
embed: &discordgo.MessageEmbed{
|
||||
Description: "blah2",
|
||||
URL: "blah3",
|
||||
},
|
||||
result: " embed: blah2 - blah3\n",
|
||||
},
|
||||
"oneb": {
|
||||
embed: &discordgo.MessageEmbed{
|
||||
URL: "blah3",
|
||||
},
|
||||
result: " embed: blah3\n",
|
||||
},
|
||||
}
|
||||
|
||||
for name, tc := range testcases {
|
||||
assert.Equalf(t, tc.result, handleEmbed(tc.embed), "Testcases %s", name)
|
||||
}
|
||||
}
|
||||
245
bridge/discord/helpers.go
Normal file
245
bridge/discord/helpers.go
Normal file
@@ -0,0 +1,245 @@
|
||||
package bdiscord
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/matterbridge/discordgo"
|
||||
)
|
||||
|
||||
func (b *Bdiscord) getNick(user *discordgo.User, guildID string) string {
|
||||
b.membersMutex.RLock()
|
||||
defer b.membersMutex.RUnlock()
|
||||
|
||||
if member, ok := b.userMemberMap[user.ID]; ok {
|
||||
if member.Nick != "" {
|
||||
// Only return if nick is set.
|
||||
return member.Nick
|
||||
}
|
||||
// Otherwise return username.
|
||||
return user.Username
|
||||
}
|
||||
|
||||
// If we didn't find nick, search for it.
|
||||
member, err := b.c.GuildMember(guildID, user.ID)
|
||||
if err != nil {
|
||||
b.Log.Warnf("Failed to fetch information for member %#v on guild %#v: %s", user, guildID, err)
|
||||
return user.Username
|
||||
} else if member == nil {
|
||||
b.Log.Warnf("Got no information for member %#v", user)
|
||||
return user.Username
|
||||
}
|
||||
b.userMemberMap[user.ID] = member
|
||||
b.nickMemberMap[member.User.Username] = member
|
||||
if member.Nick != "" {
|
||||
b.nickMemberMap[member.Nick] = member
|
||||
return member.Nick
|
||||
}
|
||||
return user.Username
|
||||
}
|
||||
|
||||
func (b *Bdiscord) getGuildMemberByNick(nick string) (*discordgo.Member, error) {
|
||||
b.membersMutex.RLock()
|
||||
defer b.membersMutex.RUnlock()
|
||||
|
||||
if member, ok := b.nickMemberMap[nick]; ok {
|
||||
return member, nil
|
||||
}
|
||||
return nil, errors.New("Couldn't find guild member with nick " + nick) // This will most likely get ignored by the caller
|
||||
}
|
||||
|
||||
func (b *Bdiscord) getChannelID(name string) string {
|
||||
if strings.Contains(name, "/") {
|
||||
return b.getCategoryChannelID(name)
|
||||
}
|
||||
b.channelsMutex.RLock()
|
||||
defer b.channelsMutex.RUnlock()
|
||||
|
||||
idcheck := strings.Split(name, "ID:")
|
||||
if len(idcheck) > 1 {
|
||||
return idcheck[1]
|
||||
}
|
||||
for _, channel := range b.channels {
|
||||
if channel.Name == name && channel.Type == discordgo.ChannelTypeGuildText {
|
||||
return channel.ID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (b *Bdiscord) getCategoryChannelID(name string) string {
|
||||
b.channelsMutex.RLock()
|
||||
defer b.channelsMutex.RUnlock()
|
||||
res := strings.Split(name, "/")
|
||||
// shouldn't happen because function should be only called from getChannelID
|
||||
if len(res) != 2 {
|
||||
return ""
|
||||
}
|
||||
catName, chanName := res[0], res[1]
|
||||
for _, channel := range b.channels {
|
||||
// if we have a parentID, lookup the name of that parent (category)
|
||||
// and if it matches return it
|
||||
if channel.Name == chanName && channel.ParentID != "" {
|
||||
for _, cat := range b.channels {
|
||||
if cat.ID == channel.ParentID && cat.Name == catName {
|
||||
return channel.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (b *Bdiscord) getChannelName(id string) string {
|
||||
b.channelsMutex.RLock()
|
||||
defer b.channelsMutex.RUnlock()
|
||||
|
||||
for _, c := range b.channelInfoMap {
|
||||
if c.Name == "ID:"+id {
|
||||
// if we have ID: specified in our gateway configuration return this
|
||||
return c.Name
|
||||
}
|
||||
}
|
||||
|
||||
for _, channel := range b.channels {
|
||||
if channel.ID == id {
|
||||
return b.getCategoryChannelName(channel.Name, channel.ParentID)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (b *Bdiscord) getCategoryChannelName(name, parentID string) string {
|
||||
var usesCat bool
|
||||
// do we have a category configuration in the channel config
|
||||
for _, c := range b.channelInfoMap {
|
||||
if strings.Contains(c.Name, "/") {
|
||||
usesCat = true
|
||||
break
|
||||
}
|
||||
}
|
||||
// configuration without category, return the normal channel name
|
||||
if !usesCat {
|
||||
return name
|
||||
}
|
||||
// create a category/channel response
|
||||
for _, c := range b.channels {
|
||||
if c.ID == parentID {
|
||||
name = c.Name + "/" + name
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
var (
|
||||
// See https://discordapp.com/developers/docs/reference#message-formatting.
|
||||
channelMentionRE = regexp.MustCompile("<#[0-9]+>")
|
||||
userMentionRE = regexp.MustCompile("@[^@\n]{1,32}")
|
||||
emoteRE = regexp.MustCompile(`<a?(:\w+:)\d+>`)
|
||||
)
|
||||
|
||||
func (b *Bdiscord) replaceChannelMentions(text string) string {
|
||||
replaceChannelMentionFunc := func(match string) string {
|
||||
channelID := match[2 : len(match)-1]
|
||||
channelName := b.getChannelName(channelID)
|
||||
|
||||
// If we don't have the channel refresh our list.
|
||||
if channelName == "" {
|
||||
var err error
|
||||
b.channels, err = b.c.GuildChannels(b.guildID)
|
||||
if err != nil {
|
||||
return "#unknownchannel"
|
||||
}
|
||||
channelName = b.getChannelName(channelID)
|
||||
}
|
||||
return "#" + channelName
|
||||
}
|
||||
return channelMentionRE.ReplaceAllStringFunc(text, replaceChannelMentionFunc)
|
||||
}
|
||||
|
||||
func (b *Bdiscord) replaceUserMentions(text string) string {
|
||||
replaceUserMentionFunc := func(match string) string {
|
||||
var (
|
||||
err error
|
||||
member *discordgo.Member
|
||||
username string
|
||||
)
|
||||
|
||||
usernames := enumerateUsernames(match[1:])
|
||||
for _, username = range usernames {
|
||||
b.Log.Debugf("Testing mention: '%s'", username)
|
||||
member, err = b.getGuildMemberByNick(username)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if member == nil {
|
||||
return match
|
||||
}
|
||||
return strings.Replace(match, "@"+username, member.User.Mention(), 1)
|
||||
}
|
||||
return userMentionRE.ReplaceAllStringFunc(text, replaceUserMentionFunc)
|
||||
}
|
||||
|
||||
func replaceEmotes(text string) string {
|
||||
return emoteRE.ReplaceAllString(text, "$1")
|
||||
}
|
||||
|
||||
func (b *Bdiscord) replaceAction(text string) (string, bool) {
|
||||
length := len(text)
|
||||
if length > 1 && text[0] == '_' && text[length-1] == '_' {
|
||||
return text[1 : length-1], true
|
||||
}
|
||||
return text, false
|
||||
}
|
||||
|
||||
// splitURL splits a webhookURL and returns the ID and token.
|
||||
func (b *Bdiscord) splitURL(url string) (string, string, bool) {
|
||||
const (
|
||||
expectedWebhookSplitCount = 7
|
||||
webhookIdxID = 5
|
||||
webhookIdxToken = 6
|
||||
)
|
||||
webhookURLSplit := strings.Split(url, "/")
|
||||
if len(webhookURLSplit) != expectedWebhookSplitCount {
|
||||
return "", "", false
|
||||
}
|
||||
return webhookURLSplit[webhookIdxID], webhookURLSplit[webhookIdxToken], true
|
||||
}
|
||||
|
||||
func enumerateUsernames(s string) []string {
|
||||
onlySpace := true
|
||||
for _, r := range s {
|
||||
if !unicode.IsSpace(r) {
|
||||
onlySpace = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if onlySpace {
|
||||
return nil
|
||||
}
|
||||
|
||||
var username, endSpace string
|
||||
var usernames []string
|
||||
skippingSpace := true
|
||||
for _, r := range s {
|
||||
if unicode.IsSpace(r) {
|
||||
if !skippingSpace {
|
||||
usernames = append(usernames, username)
|
||||
skippingSpace = true
|
||||
}
|
||||
endSpace += string(r)
|
||||
username += string(r)
|
||||
} else {
|
||||
endSpace = ""
|
||||
username += string(r)
|
||||
skippingSpace = false
|
||||
}
|
||||
}
|
||||
if endSpace == "" {
|
||||
usernames = append(usernames, username)
|
||||
}
|
||||
return usernames
|
||||
}
|
||||
46
bridge/discord/helpers_test.go
Normal file
46
bridge/discord/helpers_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package bdiscord
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestEnumerateUsernames(t *testing.T) {
|
||||
testcases := map[string]struct {
|
||||
match string
|
||||
expectedUsernames []string
|
||||
}{
|
||||
"only space": {
|
||||
match: " \t\n \t",
|
||||
expectedUsernames: nil,
|
||||
},
|
||||
"single word": {
|
||||
match: "veni",
|
||||
expectedUsernames: []string{"veni"},
|
||||
},
|
||||
"single word with preceeding space": {
|
||||
match: " vidi",
|
||||
expectedUsernames: []string{" vidi"},
|
||||
},
|
||||
"single word with suffixed space": {
|
||||
match: "vici ",
|
||||
expectedUsernames: []string{"vici"},
|
||||
},
|
||||
"multi-word with varying whitespace": {
|
||||
match: "just me and\tmy friends \t",
|
||||
expectedUsernames: []string{
|
||||
"just",
|
||||
"just me",
|
||||
"just me and",
|
||||
"just me and\tmy",
|
||||
"just me and\tmy friends",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for testname, testcase := range testcases {
|
||||
foundUsernames := enumerateUsernames(testcase.match)
|
||||
assert.Equalf(t, testcase.expectedUsernames, foundUsernames, "Should have found the expected usernames for testcase %s", testname)
|
||||
}
|
||||
}
|
||||
257
bridge/discord/transmitter/transmitter.go
Normal file
257
bridge/discord/transmitter/transmitter.go
Normal file
@@ -0,0 +1,257 @@
|
||||
// Package transmitter provides functionality for transmitting
|
||||
// arbitrary webhook messages to Discord.
|
||||
//
|
||||
// The package provides the following functionality:
|
||||
//
|
||||
// - Creating new webhooks, whenever necessary
|
||||
// - Loading webhooks that we have previously created
|
||||
// - Sending new messages
|
||||
// - Editing messages, via message ID
|
||||
// - Deleting messages, via message ID
|
||||
//
|
||||
// The package has been designed for matterbridge, but with other
|
||||
// Go bots in mind. The public API should be matterbridge-agnostic.
|
||||
package transmitter
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/matterbridge/discordgo"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// A Transmitter represents a message manager for a single guild.
|
||||
type Transmitter struct {
|
||||
session *discordgo.Session
|
||||
guild string
|
||||
title string
|
||||
autoCreate bool
|
||||
|
||||
// channelWebhooks maps from a channel ID to a webhook instance
|
||||
channelWebhooks map[string]*discordgo.Webhook
|
||||
|
||||
mutex sync.RWMutex
|
||||
|
||||
Log *log.Entry
|
||||
}
|
||||
|
||||
// ErrWebhookNotFound is returned when a valid webhook for this channel/message combination does not exist
|
||||
var ErrWebhookNotFound = errors.New("webhook for this channel and message does not exist")
|
||||
|
||||
// ErrPermissionDenied is returned if the bot does not have permission to manage webhooks.
|
||||
//
|
||||
// Bots can be granted a guild-wide permission and channel-specific permissions to manage webhooks.
|
||||
// Despite potentially having guild-wide permission, channel specific overrides could deny a bot's permission to manage webhooks.
|
||||
var ErrPermissionDenied = errors.New("missing 'Manage Webhooks' permission")
|
||||
|
||||
// New returns a new Transmitter given a Discord session, guild ID, and title.
|
||||
func New(session *discordgo.Session, guild string, title string, autoCreate bool) *Transmitter {
|
||||
return &Transmitter{
|
||||
session: session,
|
||||
guild: guild,
|
||||
title: title,
|
||||
autoCreate: autoCreate,
|
||||
|
||||
channelWebhooks: make(map[string]*discordgo.Webhook),
|
||||
|
||||
Log: log.NewEntry(nil),
|
||||
}
|
||||
}
|
||||
|
||||
// Send transmits a message to the given channel with the provided webhook data, and waits until Discord responds with message data.
|
||||
func (t *Transmitter) Send(channelID string, params *discordgo.WebhookParams) (*discordgo.Message, error) {
|
||||
wh, err := t.getOrCreateWebhook(channelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
msg, err := t.session.WebhookExecute(wh.ID, wh.Token, true, params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("execute failed: %w", err)
|
||||
}
|
||||
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// Edit will edit a message in a channel, if possible.
|
||||
func (t *Transmitter) Edit(channelID string, messageID string, params *discordgo.WebhookParams) error {
|
||||
wh := t.getWebhook(channelID)
|
||||
|
||||
if wh == nil {
|
||||
return ErrWebhookNotFound
|
||||
}
|
||||
|
||||
uri := discordgo.EndpointWebhookToken(wh.ID, wh.Token) + "/messages/" + messageID
|
||||
_, err := t.session.RequestWithBucketID("PATCH", uri, params, discordgo.EndpointWebhookToken("", ""))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasWebhook checks whether the transmitter is using a particular webhook.
|
||||
func (t *Transmitter) HasWebhook(id string) bool {
|
||||
t.mutex.RLock()
|
||||
defer t.mutex.RUnlock()
|
||||
|
||||
for _, wh := range t.channelWebhooks {
|
||||
if wh.ID == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// AddWebhook allows you to register a channel's webhook with the transmitter.
|
||||
func (t *Transmitter) AddWebhook(channelID string, webhook *discordgo.Webhook) bool {
|
||||
t.Log.Debugf("Manually added webhook %#v to channel %#v", webhook.ID, channelID)
|
||||
t.mutex.Lock()
|
||||
defer t.mutex.Unlock()
|
||||
|
||||
_, replaced := t.channelWebhooks[channelID]
|
||||
t.channelWebhooks[channelID] = webhook
|
||||
return replaced
|
||||
}
|
||||
|
||||
// RefreshGuildWebhooks loads "relevant" webhooks into the transmitter, with careful permission handling.
|
||||
//
|
||||
// Notes:
|
||||
//
|
||||
// - A webhook is "relevant" if it was created by this bot -- the ApplicationID should match the bot's ID.
|
||||
// - The term "having permission" means having the "Manage Webhooks" permission. See ErrPermissionDenied for more information.
|
||||
// - This function is additive and will not unload previously loaded webhooks.
|
||||
// - A nil channelIDs slice is treated the same as an empty one.
|
||||
//
|
||||
// If the bot has guild-wide permission:
|
||||
//
|
||||
// 1. it will load any "relevant" webhooks from the entire guild
|
||||
// 2. the given slice is ignored
|
||||
//
|
||||
// If the bot does not have guild-wide permission:
|
||||
//
|
||||
// 1. it will load any "relevant" webhooks in each channel
|
||||
// 2. a single error will be returned if any error occurs (incl. if there is no permission for any of these channels)
|
||||
//
|
||||
// If any channel has more than one "relevant" webhook, it will randomly pick one.
|
||||
func (t *Transmitter) RefreshGuildWebhooks(channelIDs []string) error {
|
||||
t.Log.Debugln("Refreshing guild webhooks")
|
||||
|
||||
botID, err := getDiscordUserID(t.session)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not get current user: %w", err)
|
||||
}
|
||||
|
||||
// Get all existing webhooks
|
||||
hooks, err := t.session.GuildWebhooks(t.guild)
|
||||
if err != nil {
|
||||
switch {
|
||||
case isDiscordPermissionError(err):
|
||||
// We fallback on manually fetching hooks from individual channels
|
||||
// if we don't have the "Manage Webhooks" permission globally.
|
||||
// We can only do this if we were provided channelIDs, though.
|
||||
if len(channelIDs) == 0 {
|
||||
return ErrPermissionDenied
|
||||
}
|
||||
t.Log.Debugln("Missing global 'Manage Webhooks' permission, falling back on per-channel permission")
|
||||
return t.fetchChannelsHooks(channelIDs, botID)
|
||||
default:
|
||||
return fmt.Errorf("could not get webhooks: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
t.Log.Debugln("Refreshing guild webhooks using global permission")
|
||||
t.assignHooksByAppID(hooks, botID, false)
|
||||
return nil
|
||||
}
|
||||
|
||||
// createWebhook creates a webhook for a specific channel.
|
||||
func (t *Transmitter) createWebhook(channel string) (*discordgo.Webhook, error) {
|
||||
t.mutex.Lock()
|
||||
defer t.mutex.Unlock()
|
||||
|
||||
wh, err := t.session.WebhookCreate(channel, t.title+time.Now().Format(" 3:04:05PM"), "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
t.channelWebhooks[channel] = wh
|
||||
return wh, nil
|
||||
}
|
||||
|
||||
func (t *Transmitter) getWebhook(channel string) *discordgo.Webhook {
|
||||
t.mutex.RLock()
|
||||
defer t.mutex.RUnlock()
|
||||
|
||||
return t.channelWebhooks[channel]
|
||||
}
|
||||
|
||||
func (t *Transmitter) getOrCreateWebhook(channelID string) (*discordgo.Webhook, error) {
|
||||
// If we have a webhook for this channel, immediately return it
|
||||
wh := t.getWebhook(channelID)
|
||||
if wh != nil {
|
||||
return wh, nil
|
||||
}
|
||||
|
||||
// Early exit if we don't want to automatically create one
|
||||
if !t.autoCreate {
|
||||
return nil, ErrWebhookNotFound
|
||||
}
|
||||
|
||||
t.Log.Infof("Creating a webhook for %s\n", channelID)
|
||||
wh, err := t.createWebhook(channelID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not create webhook: %w", err)
|
||||
}
|
||||
|
||||
return wh, nil
|
||||
}
|
||||
|
||||
// fetchChannelsHooks fetches hooks for the given channelIDs and calls assignHooksByAppID for each channel's hooks
|
||||
func (t *Transmitter) fetchChannelsHooks(channelIDs []string, botID string) error {
|
||||
// For each channel, search for relevant hooks
|
||||
var failedHooks []string
|
||||
for _, channelID := range channelIDs {
|
||||
hooks, err := t.session.ChannelWebhooks(channelID)
|
||||
if err != nil {
|
||||
failedHooks = append(failedHooks, "\n- "+channelID+": "+err.Error())
|
||||
continue
|
||||
}
|
||||
t.assignHooksByAppID(hooks, botID, true)
|
||||
}
|
||||
|
||||
// Compose an error if any hooks failed
|
||||
if len(failedHooks) > 0 {
|
||||
return errors.New("failed to fetch hooks:" + strings.Join(failedHooks, ""))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *Transmitter) assignHooksByAppID(hooks []*discordgo.Webhook, appID string, channelTargeted bool) {
|
||||
logLine := "Picking up webhook"
|
||||
if channelTargeted {
|
||||
logLine += " (channel targeted)"
|
||||
}
|
||||
|
||||
t.mutex.Lock()
|
||||
defer t.mutex.Unlock()
|
||||
|
||||
for _, wh := range hooks {
|
||||
if wh.ApplicationID != appID {
|
||||
continue
|
||||
}
|
||||
|
||||
t.channelWebhooks[wh.ChannelID] = wh
|
||||
t.Log.WithFields(log.Fields{
|
||||
"id": wh.ID,
|
||||
"name": wh.Name,
|
||||
"channel": wh.ChannelID,
|
||||
}).Println(logLine)
|
||||
}
|
||||
}
|
||||
32
bridge/discord/transmitter/utils.go
Normal file
32
bridge/discord/transmitter/utils.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package transmitter
|
||||
|
||||
import (
|
||||
"github.com/matterbridge/discordgo"
|
||||
)
|
||||
|
||||
// isDiscordPermissionError returns false for nil, and true if a Discord RESTError with code discordgo.ErrorCodeMissionPermissions
|
||||
func isDiscordPermissionError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
restErr, ok := err.(*discordgo.RESTError)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
return restErr.Message != nil && restErr.Message.Code == discordgo.ErrCodeMissingPermissions
|
||||
}
|
||||
|
||||
// getDiscordUserID gets own user ID from state, and fallback on API request
|
||||
func getDiscordUserID(session *discordgo.Session) (string, error) {
|
||||
if user := session.State.User; user != nil {
|
||||
return user.ID, nil
|
||||
}
|
||||
|
||||
user, err := session.User("@me")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return user.ID, nil
|
||||
}
|
||||
147
bridge/discord/webhook.go
Normal file
147
bridge/discord/webhook.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package bdiscord
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
"github.com/42wim/matterbridge/bridge/helper"
|
||||
"github.com/matterbridge/discordgo"
|
||||
)
|
||||
|
||||
// shouldMessageUseWebhooks checks if have a channel specific webhook, if we're not using auto webhooks
|
||||
func (b *Bdiscord) shouldMessageUseWebhooks(msg *config.Message) bool {
|
||||
if b.useAutoWebhooks {
|
||||
return true
|
||||
}
|
||||
|
||||
b.channelsMutex.RLock()
|
||||
defer b.channelsMutex.RUnlock()
|
||||
if ci, ok := b.channelInfoMap[msg.Channel+b.Account]; ok {
|
||||
if ci.Options.WebhookURL != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// maybeGetLocalAvatar checks if UseLocalAvatar contains the message's
|
||||
// account or protocol, and if so, returns the Discord avatar (if exists)
|
||||
func (b *Bdiscord) maybeGetLocalAvatar(msg *config.Message) string {
|
||||
for _, val := range b.GetStringSlice("UseLocalAvatar") {
|
||||
if msg.Protocol != val && msg.Account != val {
|
||||
continue
|
||||
}
|
||||
|
||||
member, err := b.getGuildMemberByNick(msg.Username)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return member.User.AvatarURL("")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// webhookSend send one or more message via webhook, taking care of file
|
||||
// uploads (from slack, telegram or mattermost).
|
||||
// Returns messageID and error.
|
||||
func (b *Bdiscord) webhookSend(msg *config.Message, channelID string) (*discordgo.Message, error) {
|
||||
var (
|
||||
res *discordgo.Message
|
||||
err error
|
||||
)
|
||||
|
||||
// If avatar is unset, mutate the message to include the local avatar (but only if settings say we should do this)
|
||||
if msg.Avatar == "" {
|
||||
msg.Avatar = b.maybeGetLocalAvatar(msg)
|
||||
}
|
||||
|
||||
// WebhookParams can have either `Content` or `File`.
|
||||
|
||||
// We can't send empty messages.
|
||||
if msg.Text != "" {
|
||||
res, err = b.transmitter.Send(
|
||||
channelID,
|
||||
&discordgo.WebhookParams{
|
||||
Content: msg.Text,
|
||||
Username: msg.Username,
|
||||
AvatarURL: msg.Avatar,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
b.Log.Errorf("Could not send text (%s) for message %#v: %s", msg.Text, msg, err)
|
||||
}
|
||||
}
|
||||
|
||||
if msg.Extra != nil {
|
||||
for _, f := range msg.Extra["file"] {
|
||||
fi := f.(config.FileInfo)
|
||||
file := discordgo.File{
|
||||
Name: fi.Name,
|
||||
ContentType: "",
|
||||
Reader: bytes.NewReader(*fi.Data),
|
||||
}
|
||||
content := ""
|
||||
if msg.Text == "" {
|
||||
content = fi.Comment
|
||||
}
|
||||
_, e2 := b.transmitter.Send(
|
||||
channelID,
|
||||
&discordgo.WebhookParams{
|
||||
Username: msg.Username,
|
||||
AvatarURL: msg.Avatar,
|
||||
File: &file,
|
||||
Content: content,
|
||||
},
|
||||
)
|
||||
if e2 != nil {
|
||||
b.Log.Errorf("Could not send file %#v for message %#v: %s", file, msg, e2)
|
||||
}
|
||||
}
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (b *Bdiscord) handleEventWebhook(msg *config.Message, channelID string) (string, error) {
|
||||
// skip events
|
||||
if msg.Event != "" && msg.Event != config.EventUserAction && msg.Event != config.EventJoinLeave && msg.Event != config.EventTopicChange {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// skip empty messages
|
||||
if msg.Text == "" && (msg.Extra == nil || len(msg.Extra["file"]) == 0) {
|
||||
b.Log.Debugf("Skipping empty message %#v", msg)
|
||||
return "", nil
|
||||
}
|
||||
|
||||
msg.Text = helper.ClipMessage(msg.Text, MessageLength)
|
||||
msg.Text = b.replaceUserMentions(msg.Text)
|
||||
// discord username must be [0..32] max
|
||||
if len(msg.Username) > 32 {
|
||||
msg.Username = msg.Username[0:32]
|
||||
}
|
||||
|
||||
if msg.ID != "" {
|
||||
b.Log.Debugf("Editing webhook message")
|
||||
err := b.transmitter.Edit(channelID, msg.ID, &discordgo.WebhookParams{
|
||||
Content: msg.Text,
|
||||
Username: msg.Username,
|
||||
})
|
||||
if err == nil {
|
||||
return msg.ID, nil
|
||||
}
|
||||
b.Log.Errorf("Could not edit webhook message: %s", err)
|
||||
}
|
||||
|
||||
b.Log.Debugf("Processing webhook sending for message %#v", msg)
|
||||
discordMsg, err := b.webhookSend(msg, channelID)
|
||||
if err != nil {
|
||||
b.Log.Errorf("Could not broadcast via webhook for message %#v: %s", msg, err)
|
||||
return "", err
|
||||
}
|
||||
if discordMsg == nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
return discordMsg.ID, nil
|
||||
}
|
||||
@@ -2,48 +2,39 @@ package bgitter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/42wim/go-gitter"
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
log "github.com/Sirupsen/logrus"
|
||||
"strings"
|
||||
|
||||
"github.com/42wim/go-gitter"
|
||||
"github.com/42wim/matterbridge/bridge"
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
"github.com/42wim/matterbridge/bridge/helper"
|
||||
)
|
||||
|
||||
type Bgitter struct {
|
||||
c *gitter.Gitter
|
||||
Config *config.Protocol
|
||||
Remote chan config.Message
|
||||
Account string
|
||||
User *gitter.User
|
||||
Users []gitter.User
|
||||
Rooms []gitter.Room
|
||||
c *gitter.Gitter
|
||||
User *gitter.User
|
||||
Users []gitter.User
|
||||
Rooms []gitter.Room
|
||||
*bridge.Config
|
||||
}
|
||||
|
||||
var flog *log.Entry
|
||||
var protocol = "gitter"
|
||||
|
||||
func init() {
|
||||
flog = log.WithFields(log.Fields{"module": protocol})
|
||||
}
|
||||
|
||||
func New(cfg config.Protocol, account string, c chan config.Message) *Bgitter {
|
||||
b := &Bgitter{}
|
||||
b.Config = &cfg
|
||||
b.Remote = c
|
||||
b.Account = account
|
||||
return b
|
||||
func New(cfg *bridge.Config) bridge.Bridger {
|
||||
return &Bgitter{Config: cfg}
|
||||
}
|
||||
|
||||
func (b *Bgitter) Connect() error {
|
||||
var err error
|
||||
flog.Info("Connecting")
|
||||
b.c = gitter.New(b.Config.Token)
|
||||
b.Log.Info("Connecting")
|
||||
b.c = gitter.New(b.GetString("Token"))
|
||||
b.User, err = b.c.GetUser()
|
||||
if err != nil {
|
||||
flog.Debugf("%#v", err)
|
||||
return err
|
||||
}
|
||||
flog.Info("Connection succeeded")
|
||||
b.Rooms, _ = b.c.GetRooms()
|
||||
b.Rooms, err = b.c.GetRooms()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b.Log.Info("Connection succeeded")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -79,52 +70,69 @@ func (b *Bgitter) JoinChannel(channel config.ChannelInfo) error {
|
||||
for event := range stream.Event {
|
||||
switch ev := event.Data.(type) {
|
||||
case *gitter.MessageReceived:
|
||||
// ignore message sent from ourselves
|
||||
if ev.Message.From.ID != b.User.ID {
|
||||
flog.Debugf("Sending message from %s on %s to gateway", ev.Message.From.Username, b.Account)
|
||||
b.Log.Debugf("<= Sending message from %s on %s to gateway", ev.Message.From.Username, b.Account)
|
||||
rmsg := config.Message{Username: ev.Message.From.Username, Text: ev.Message.Text, Channel: room,
|
||||
Account: b.Account, Avatar: b.getAvatar(ev.Message.From.Username), UserID: ev.Message.From.ID,
|
||||
ID: ev.Message.ID}
|
||||
if strings.HasPrefix(ev.Message.Text, "@"+ev.Message.From.Username) {
|
||||
rmsg.Event = config.EVENT_USER_ACTION
|
||||
rmsg.Event = config.EventUserAction
|
||||
rmsg.Text = strings.Replace(rmsg.Text, "@"+ev.Message.From.Username+" ", "", -1)
|
||||
}
|
||||
flog.Debugf("Message is %#v", rmsg)
|
||||
b.Log.Debugf("<= Message is %#v", rmsg)
|
||||
b.Remote <- rmsg
|
||||
}
|
||||
case *gitter.GitterConnectionClosed:
|
||||
flog.Errorf("connection with gitter closed for room %s", room)
|
||||
b.Log.Errorf("connection with gitter closed for room %s", room)
|
||||
}
|
||||
}
|
||||
}(stream, room.Name)
|
||||
}(stream, room.URI)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bgitter) Send(msg config.Message) (string, error) {
|
||||
flog.Debugf("Receiving %#v", msg)
|
||||
b.Log.Debugf("=> Receiving %#v", msg)
|
||||
roomID := b.getRoomID(msg.Channel)
|
||||
if roomID == "" {
|
||||
flog.Errorf("Could not find roomID for %v", msg.Channel)
|
||||
b.Log.Errorf("Could not find roomID for %v", msg.Channel)
|
||||
return "", nil
|
||||
}
|
||||
if msg.Event == config.EVENT_MSG_DELETE {
|
||||
|
||||
// Delete message
|
||||
if msg.Event == config.EventMsgDelete {
|
||||
if msg.ID == "" {
|
||||
return "", nil
|
||||
}
|
||||
// gitter has no delete message api
|
||||
// gitter has no delete message api so we edit message to ""
|
||||
_, err := b.c.UpdateMessage(roomID, msg.ID, "")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Upload a file (in gitter case send the upload URL because gitter has no native upload support)
|
||||
if msg.Extra != nil {
|
||||
for _, rmsg := range helper.HandleExtra(&msg, b.General) {
|
||||
b.c.SendMessage(roomID, rmsg.Username+rmsg.Text)
|
||||
}
|
||||
if len(msg.Extra["file"]) > 0 {
|
||||
return b.handleUploadFile(&msg, roomID)
|
||||
}
|
||||
}
|
||||
|
||||
// Edit message
|
||||
if msg.ID != "" {
|
||||
flog.Debugf("updating message with id %s", msg.ID)
|
||||
b.Log.Debugf("updating message with id %s", msg.ID)
|
||||
_, err := b.c.UpdateMessage(roomID, msg.ID, msg.Username+msg.Text)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Post normal message
|
||||
resp, err := b.c.SendMessage(roomID, msg.Username+msg.Text)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -152,3 +160,23 @@ func (b *Bgitter) getAvatar(user string) string {
|
||||
}
|
||||
return avatar
|
||||
}
|
||||
|
||||
func (b *Bgitter) handleUploadFile(msg *config.Message, roomID string) (string, error) {
|
||||
for _, f := range msg.Extra["file"] {
|
||||
fi := f.(config.FileInfo)
|
||||
if fi.Comment != "" {
|
||||
msg.Text += fi.Comment + ": "
|
||||
}
|
||||
if fi.URL != "" {
|
||||
msg.Text = fi.URL
|
||||
if fi.Comment != "" {
|
||||
msg.Text = fi.Comment + ": " + fi.URL
|
||||
}
|
||||
}
|
||||
_, err := b.c.SendMessage(roomID, msg.Username+msg.Text)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
258
bridge/helper/helper.go
Normal file
258
bridge/helper/helper.go
Normal file
@@ -0,0 +1,258 @@
|
||||
package helper
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image/png"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/image/webp"
|
||||
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
"github.com/gomarkdown/markdown"
|
||||
"github.com/gomarkdown/markdown/html"
|
||||
"github.com/gomarkdown/markdown/parser"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// DownloadFile downloads the given non-authenticated URL.
|
||||
func DownloadFile(url string) (*[]byte, error) {
|
||||
return DownloadFileAuth(url, "")
|
||||
}
|
||||
|
||||
// DownloadFileAuth downloads the given URL using the specified authentication token.
|
||||
func DownloadFileAuth(url string, auth string) (*[]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
client := &http.Client{
|
||||
Timeout: time.Second * 5,
|
||||
}
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if auth != "" {
|
||||
req.Header.Add("Authorization", auth)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
io.Copy(&buf, resp.Body)
|
||||
data := buf.Bytes()
|
||||
return &data, nil
|
||||
}
|
||||
|
||||
// GetSubLines splits messages in newline-delimited lines. If maxLineLength is
|
||||
// specified as non-zero GetSubLines will also clip long lines to the maximum
|
||||
// length and insert a warning marker that the line was clipped.
|
||||
//
|
||||
// TODO: The current implementation has the inconvenient that it disregards
|
||||
// word boundaries when splitting but this is hard to solve without potentially
|
||||
// breaking formatting and other stylistic effects.
|
||||
func GetSubLines(message string, maxLineLength int) []string {
|
||||
const clippingMessage = " <clipped message>"
|
||||
|
||||
var lines []string
|
||||
for _, line := range strings.Split(strings.TrimSpace(message), "\n") {
|
||||
if maxLineLength == 0 || len([]byte(line)) <= maxLineLength {
|
||||
lines = append(lines, line)
|
||||
continue
|
||||
}
|
||||
|
||||
// !!! WARNING !!!
|
||||
// Before touching the splitting logic below please ensure that you PROPERLY
|
||||
// understand how strings, runes and range loops over strings work in Go.
|
||||
// A good place to start is to read https://blog.golang.org/strings. :-)
|
||||
var splitStart int
|
||||
var startOfPreviousRune int
|
||||
for i := range line {
|
||||
if i-splitStart > maxLineLength-len([]byte(clippingMessage)) {
|
||||
lines = append(lines, line[splitStart:startOfPreviousRune]+clippingMessage)
|
||||
splitStart = startOfPreviousRune
|
||||
}
|
||||
startOfPreviousRune = i
|
||||
}
|
||||
// This last append is safe to do without looking at the remaining byte-length
|
||||
// as we assume that the byte-length of the last rune will never exceed that of
|
||||
// the byte-length of the clipping message.
|
||||
lines = append(lines, line[splitStart:])
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// HandleExtra manages the supplementary details stored inside a message's 'Extra' field map.
|
||||
func HandleExtra(msg *config.Message, general *config.Protocol) []config.Message {
|
||||
extra := msg.Extra
|
||||
rmsg := []config.Message{}
|
||||
for _, f := range extra[config.EventFileFailureSize] {
|
||||
fi := f.(config.FileInfo)
|
||||
text := fmt.Sprintf("file %s too big to download (%#v > allowed size: %#v)", fi.Name, fi.Size, general.MediaDownloadSize)
|
||||
rmsg = append(rmsg, config.Message{
|
||||
Text: text,
|
||||
Username: "<system> ",
|
||||
Channel: msg.Channel,
|
||||
Account: msg.Account,
|
||||
})
|
||||
}
|
||||
return rmsg
|
||||
}
|
||||
|
||||
// GetAvatar constructs a URL for a given user-avatar if it is available in the cache.
|
||||
func GetAvatar(av map[string]string, userid string, general *config.Protocol) string {
|
||||
if sha, ok := av[userid]; ok {
|
||||
return general.MediaServerDownload + "/" + sha + "/" + userid + ".png"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// HandleDownloadSize checks a specified filename against the configured download blacklist
|
||||
// and checks a specified file-size against the configure limit.
|
||||
func HandleDownloadSize(logger *logrus.Entry, msg *config.Message, name string, size int64, general *config.Protocol) error {
|
||||
// check blacklist here
|
||||
for _, entry := range general.MediaDownloadBlackList {
|
||||
if entry != "" {
|
||||
re, err := regexp.Compile(entry)
|
||||
if err != nil {
|
||||
logger.Errorf("incorrect regexp %s for %s", entry, msg.Account)
|
||||
continue
|
||||
}
|
||||
if re.MatchString(name) {
|
||||
return fmt.Errorf("Matching blacklist %s. Not downloading %s", entry, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
logger.Debugf("Trying to download %#v with size %#v", name, size)
|
||||
if int(size) > general.MediaDownloadSize {
|
||||
msg.Event = config.EventFileFailureSize
|
||||
msg.Extra[msg.Event] = append(msg.Extra[msg.Event], config.FileInfo{
|
||||
Name: name,
|
||||
Comment: msg.Text,
|
||||
Size: size,
|
||||
})
|
||||
return fmt.Errorf("File %#v to large to download (%#v). MediaDownloadSize is %#v", name, size, general.MediaDownloadSize)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleDownloadData adds the data for a remote file into a Matterbridge gateway message.
|
||||
func HandleDownloadData(logger *logrus.Entry, msg *config.Message, name, comment, url string, data *[]byte, general *config.Protocol) {
|
||||
var avatar bool
|
||||
logger.Debugf("Download OK %#v %#v", name, len(*data))
|
||||
if msg.Event == config.EventAvatarDownload {
|
||||
avatar = true
|
||||
}
|
||||
msg.Extra["file"] = append(msg.Extra["file"], config.FileInfo{
|
||||
Name: name,
|
||||
Data: data,
|
||||
URL: url,
|
||||
Comment: comment,
|
||||
Avatar: avatar,
|
||||
})
|
||||
}
|
||||
|
||||
var emptyLineMatcher = regexp.MustCompile("\n+")
|
||||
|
||||
// RemoveEmptyNewLines collapses consecutive newline characters into a single one and
|
||||
// trims any preceding or trailing newline characters as well.
|
||||
func RemoveEmptyNewLines(msg string) string {
|
||||
return emptyLineMatcher.ReplaceAllString(strings.Trim(msg, "\n"), "\n")
|
||||
}
|
||||
|
||||
// ClipMessage trims a message to the specified length if it exceeds it and adds a warning
|
||||
// to the message in case it does so.
|
||||
func ClipMessage(text string, length int) string {
|
||||
const clippingMessage = " <clipped message>"
|
||||
if len(text) > length {
|
||||
text = text[:length-len(clippingMessage)]
|
||||
if r, size := utf8.DecodeLastRuneInString(text); r == utf8.RuneError {
|
||||
text = text[:len(text)-size]
|
||||
}
|
||||
text += clippingMessage
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// ParseMarkdown takes in an input string as markdown and parses it to html
|
||||
func ParseMarkdown(input string) string {
|
||||
extensions := parser.HardLineBreak | parser.NoIntraEmphasis | parser.FencedCode
|
||||
markdownParser := parser.NewWithExtensions(extensions)
|
||||
renderer := html.NewRenderer(html.RendererOptions{
|
||||
Flags: 0,
|
||||
})
|
||||
parsedMarkdown := markdown.ToHTML([]byte(input), markdownParser, renderer)
|
||||
res := string(parsedMarkdown)
|
||||
res = strings.TrimPrefix(res, "<p>")
|
||||
res = strings.TrimSuffix(res, "</p>\n")
|
||||
return res
|
||||
}
|
||||
|
||||
// ConvertWebPToPNG converts input data (which should be WebP format) to PNG format
|
||||
func ConvertWebPToPNG(data *[]byte) error {
|
||||
r := bytes.NewReader(*data)
|
||||
m, err := webp.Decode(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var output []byte
|
||||
w := bytes.NewBuffer(output)
|
||||
if err := png.Encode(w, m); err != nil {
|
||||
return err
|
||||
}
|
||||
*data = w.Bytes()
|
||||
return nil
|
||||
}
|
||||
|
||||
// CanConvertTgsToX Checks whether the external command necessary for ConvertTgsToX works.
|
||||
func CanConvertTgsToX() error {
|
||||
// We depend on the fact that `lottie_convert.py --help` has exit status 0.
|
||||
// Hyrum's Law predicted this, and Murphy's Law predicts that this will break eventually.
|
||||
// However, there is no alternative like `lottie_convert.py --is-properly-installed`
|
||||
cmd := exec.Command("lottie_convert.py", "--help")
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// ConvertTgsToWebP convert input data (which should be tgs format) to WebP format
|
||||
// This relies on an external command, which is ugly, but works.
|
||||
func ConvertTgsToX(data *[]byte, outputFormat string, logger *logrus.Entry) error {
|
||||
// lottie can't handle input from a pipe, so write to a temporary file:
|
||||
tmpFile, err := ioutil.TempFile(os.TempDir(), "matterbridge-lottie-*.tgs")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpFileName := tmpFile.Name()
|
||||
defer func() {
|
||||
if removeErr := os.Remove(tmpFileName); removeErr != nil {
|
||||
logger.Errorf("Could not delete temporary file %s: %v", tmpFileName, removeErr)
|
||||
}
|
||||
}()
|
||||
|
||||
if _, writeErr := tmpFile.Write(*data); writeErr != nil {
|
||||
return writeErr
|
||||
}
|
||||
// Must close before calling lottie to avoid data races:
|
||||
if closeErr := tmpFile.Close(); closeErr != nil {
|
||||
return closeErr
|
||||
}
|
||||
|
||||
// Call lottie to transform:
|
||||
cmd := exec.Command("lottie_convert.py", "--input-format", "lottie", "--output-format", outputFormat, tmpFileName, "/dev/stdout")
|
||||
cmd.Stderr = nil
|
||||
// NB: lottie writes progress into to stderr in all cases.
|
||||
stdout, stderr := cmd.Output()
|
||||
if stderr != nil {
|
||||
// 'stderr' already contains some parts of Stderr, because it was set to 'nil'.
|
||||
return stderr
|
||||
}
|
||||
|
||||
*data = stdout
|
||||
return nil
|
||||
}
|
||||
126
bridge/helper/helper_test.go
Normal file
126
bridge/helper/helper_test.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package helper
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
const testLineLength = 64
|
||||
|
||||
var (
|
||||
lineSplittingTestCases = map[string]struct {
|
||||
input string
|
||||
splitOutput []string
|
||||
nonSplitOutput []string
|
||||
}{
|
||||
"Short single-line message": {
|
||||
input: "short",
|
||||
splitOutput: []string{"short"},
|
||||
nonSplitOutput: []string{"short"},
|
||||
},
|
||||
"Long single-line message": {
|
||||
input: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
|
||||
splitOutput: []string{
|
||||
"Lorem ipsum dolor sit amet, consectetur adipis <clipped message>",
|
||||
"cing elit, sed do eiusmod tempor incididunt ut <clipped message>",
|
||||
" labore et dolore magna aliqua.",
|
||||
},
|
||||
nonSplitOutput: []string{"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."},
|
||||
},
|
||||
"Short multi-line message": {
|
||||
input: "I\ncan't\nget\nno\nsatisfaction!",
|
||||
splitOutput: []string{
|
||||
"I",
|
||||
"can't",
|
||||
"get",
|
||||
"no",
|
||||
"satisfaction!",
|
||||
},
|
||||
nonSplitOutput: []string{
|
||||
"I",
|
||||
"can't",
|
||||
"get",
|
||||
"no",
|
||||
"satisfaction!",
|
||||
},
|
||||
},
|
||||
"Long multi-line message": {
|
||||
input: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.\n" +
|
||||
"Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.\n" +
|
||||
"Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.\n" +
|
||||
"Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.",
|
||||
splitOutput: []string{
|
||||
"Lorem ipsum dolor sit amet, consectetur adipis <clipped message>",
|
||||
"cing elit, sed do eiusmod tempor incididunt ut <clipped message>",
|
||||
" labore et dolore magna aliqua.",
|
||||
"Ut enim ad minim veniam, quis nostrud exercita <clipped message>",
|
||||
"tion ullamco laboris nisi ut aliquip ex ea com <clipped message>",
|
||||
"modo consequat.",
|
||||
"Duis aute irure dolor in reprehenderit in volu <clipped message>",
|
||||
"ptate velit esse cillum dolore eu fugiat nulla <clipped message>",
|
||||
" pariatur.",
|
||||
"Excepteur sint occaecat cupidatat non proident <clipped message>",
|
||||
", sunt in culpa qui officia deserunt mollit an <clipped message>",
|
||||
"im id est laborum.",
|
||||
},
|
||||
nonSplitOutput: []string{
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
|
||||
"Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.",
|
||||
"Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.",
|
||||
"Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.",
|
||||
},
|
||||
},
|
||||
"Message ending with new-line.": {
|
||||
input: "Newline ending\n",
|
||||
splitOutput: []string{"Newline ending"},
|
||||
nonSplitOutput: []string{"Newline ending"},
|
||||
},
|
||||
"Long message containing UTF-8 multi-byte runes": {
|
||||
input: "不布人個我此而及單石業喜資富下我河下日沒一我臺空達的常景便物沒為……子大我別名解成?生賣的全直黑,我自我結毛分洲了世當,是政福那是東;斯說",
|
||||
splitOutput: []string{
|
||||
"不布人個我此而及單石業喜資富下 <clipped message>",
|
||||
"我河下日沒一我臺空達的常景便物 <clipped message>",
|
||||
"沒為……子大我別名解成?生賣的 <clipped message>",
|
||||
"全直黑,我自我結毛分洲了世當, <clipped message>",
|
||||
"是政福那是東;斯說",
|
||||
},
|
||||
nonSplitOutput: []string{"不布人個我此而及單石業喜資富下我河下日沒一我臺空達的常景便物沒為……子大我別名解成?生賣的全直黑,我自我結毛分洲了世當,是政福那是東;斯說"},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
func TestGetSubLines(t *testing.T) {
|
||||
for testname, testcase := range lineSplittingTestCases {
|
||||
splitLines := GetSubLines(testcase.input, testLineLength)
|
||||
assert.Equalf(t, testcase.splitOutput, splitLines, "'%s' testcase should give expected lines with splitting.", testname)
|
||||
for _, splitLine := range splitLines {
|
||||
byteLength := len([]byte(splitLine))
|
||||
assert.True(t, byteLength <= testLineLength, "Splitted line '%s' of testcase '%s' should not exceed the maximum byte-length (%d vs. %d).", splitLine, testcase, byteLength, testLineLength)
|
||||
}
|
||||
|
||||
nonSplitLines := GetSubLines(testcase.input, 0)
|
||||
assert.Equalf(t, testcase.nonSplitOutput, nonSplitLines, "'%s' testcase should give expected lines without splitting.", testname)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertWebPToPNG(t *testing.T) {
|
||||
if os.Getenv("LOCAL_TEST") == "" {
|
||||
t.Skip()
|
||||
}
|
||||
input, err := ioutil.ReadFile("test.webp")
|
||||
if err != nil {
|
||||
t.Fail()
|
||||
}
|
||||
d := &input
|
||||
err = ConvertWebPToPNG(d)
|
||||
if err != nil {
|
||||
t.Fail()
|
||||
}
|
||||
err = ioutil.WriteFile("test.png", *d, 0644)
|
||||
if err != nil {
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
265
bridge/irc/handlers.go
Normal file
265
bridge/irc/handlers.go
Normal file
@@ -0,0 +1,265 @@
|
||||
package birc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
"github.com/42wim/matterbridge/bridge/helper"
|
||||
"github.com/lrstanley/girc"
|
||||
"github.com/missdeer/golib/ic"
|
||||
"github.com/paulrosania/go-charset/charset"
|
||||
"github.com/saintfish/chardet"
|
||||
|
||||
// We need to import the 'data' package as an implicit dependency.
|
||||
// See: https://godoc.org/github.com/paulrosania/go-charset/charset
|
||||
_ "github.com/paulrosania/go-charset/data"
|
||||
)
|
||||
|
||||
func (b *Birc) handleCharset(msg *config.Message) error {
|
||||
if b.GetString("Charset") != "" {
|
||||
switch b.GetString("Charset") {
|
||||
case "gbk", "gb18030", "gb2312", "big5", "euc-kr", "euc-jp", "shift-jis", "iso-2022-jp":
|
||||
msg.Text = ic.ConvertString("utf-8", b.GetString("Charset"), msg.Text)
|
||||
default:
|
||||
buf := new(bytes.Buffer)
|
||||
w, err := charset.NewWriter(b.GetString("Charset"), buf)
|
||||
if err != nil {
|
||||
b.Log.Errorf("charset from utf-8 conversion failed: %s", err)
|
||||
return err
|
||||
}
|
||||
fmt.Fprint(w, msg.Text)
|
||||
w.Close()
|
||||
msg.Text = buf.String()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleFiles returns true if we have handled the files, otherwise return false
|
||||
func (b *Birc) handleFiles(msg *config.Message) bool {
|
||||
if msg.Extra == nil {
|
||||
return false
|
||||
}
|
||||
for _, rmsg := range helper.HandleExtra(msg, b.General) {
|
||||
b.Local <- rmsg
|
||||
}
|
||||
if len(msg.Extra["file"]) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, f := range msg.Extra["file"] {
|
||||
fi := f.(config.FileInfo)
|
||||
if fi.Comment != "" {
|
||||
msg.Text += fi.Comment + " : "
|
||||
}
|
||||
if fi.URL != "" {
|
||||
msg.Text = fi.URL
|
||||
if fi.Comment != "" {
|
||||
msg.Text = fi.Comment + " : " + fi.URL
|
||||
}
|
||||
}
|
||||
b.Local <- config.Message{Text: msg.Text, Username: msg.Username, Channel: msg.Channel, Event: msg.Event}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (b *Birc) handleInvite(client *girc.Client, event girc.Event) {
|
||||
if len(event.Params) != 2 {
|
||||
return
|
||||
}
|
||||
|
||||
channel := event.Params[1]
|
||||
|
||||
b.Log.Debugf("got invite for %s", channel)
|
||||
|
||||
if _, ok := b.channels[channel]; ok {
|
||||
b.i.Cmd.Join(channel)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Birc) handleJoinPart(client *girc.Client, event girc.Event) {
|
||||
if len(event.Params) == 0 {
|
||||
b.Log.Debugf("handleJoinPart: empty Params? %#v", event)
|
||||
return
|
||||
}
|
||||
channel := strings.ToLower(event.Params[0])
|
||||
if event.Command == "KICK" && event.Params[1] == b.Nick {
|
||||
b.Log.Infof("Got kicked from %s by %s", channel, event.Source.Name)
|
||||
time.Sleep(time.Duration(b.GetInt("RejoinDelay")) * time.Second)
|
||||
b.Remote <- config.Message{Username: "system", Text: "rejoin", Channel: channel, Account: b.Account, Event: config.EventRejoinChannels}
|
||||
return
|
||||
}
|
||||
if event.Command == "QUIT" {
|
||||
if event.Source.Name == b.Nick && strings.Contains(event.Last(), "Ping timeout") {
|
||||
b.Log.Infof("%s reconnecting ..", b.Account)
|
||||
b.Remote <- config.Message{Username: "system", Text: "reconnect", Channel: channel, Account: b.Account, Event: config.EventFailure}
|
||||
return
|
||||
}
|
||||
}
|
||||
if event.Source.Name != b.Nick {
|
||||
if b.GetBool("nosendjoinpart") {
|
||||
return
|
||||
}
|
||||
msg := config.Message{Username: "system", Text: event.Source.Name + " " + strings.ToLower(event.Command) + "s", Channel: channel, Account: b.Account, Event: config.EventJoinLeave}
|
||||
if b.GetBool("verbosejoinpart") {
|
||||
b.Log.Debugf("<= Sending verbose JOIN_LEAVE event from %s to gateway", b.Account)
|
||||
msg = config.Message{Username: "system", Text: event.Source.Name + " (" + event.Source.Ident + "@" + event.Source.Host + ") " + strings.ToLower(event.Command) + "s", Channel: channel, Account: b.Account, Event: config.EventJoinLeave}
|
||||
} else {
|
||||
b.Log.Debugf("<= Sending JOIN_LEAVE event from %s to gateway", b.Account)
|
||||
}
|
||||
b.Log.Debugf("<= Message is %#v", msg)
|
||||
b.Remote <- msg
|
||||
return
|
||||
}
|
||||
b.Log.Debugf("handle %#v", event)
|
||||
}
|
||||
|
||||
func (b *Birc) handleNewConnection(client *girc.Client, event girc.Event) {
|
||||
b.Log.Debug("Registering callbacks")
|
||||
i := b.i
|
||||
b.Nick = event.Params[0]
|
||||
|
||||
i.Handlers.AddBg("PRIVMSG", b.handlePrivMsg)
|
||||
i.Handlers.AddBg("CTCP_ACTION", b.handlePrivMsg)
|
||||
i.Handlers.Add(girc.RPL_TOPICWHOTIME, b.handleTopicWhoTime)
|
||||
i.Handlers.AddBg(girc.NOTICE, b.handleNotice)
|
||||
i.Handlers.AddBg("JOIN", b.handleJoinPart)
|
||||
i.Handlers.AddBg("PART", b.handleJoinPart)
|
||||
i.Handlers.AddBg("QUIT", b.handleJoinPart)
|
||||
i.Handlers.AddBg("KICK", b.handleJoinPart)
|
||||
i.Handlers.Add("INVITE", b.handleInvite)
|
||||
}
|
||||
|
||||
func (b *Birc) handleNickServ() {
|
||||
if !b.GetBool("UseSASL") && b.GetString("NickServNick") != "" && b.GetString("NickServPassword") != "" {
|
||||
b.Log.Debugf("Sending identify to nickserv %s", b.GetString("NickServNick"))
|
||||
b.i.Cmd.Message(b.GetString("NickServNick"), "IDENTIFY "+b.GetString("NickServPassword"))
|
||||
}
|
||||
if strings.EqualFold(b.GetString("NickServNick"), "Q@CServe.quakenet.org") {
|
||||
b.Log.Debugf("Authenticating %s against %s", b.GetString("NickServUsername"), b.GetString("NickServNick"))
|
||||
b.i.Cmd.Message(b.GetString("NickServNick"), "AUTH "+b.GetString("NickServUsername")+" "+b.GetString("NickServPassword"))
|
||||
}
|
||||
// give nickserv some slack
|
||||
time.Sleep(time.Second * 5)
|
||||
b.authDone = true
|
||||
}
|
||||
|
||||
func (b *Birc) handleNotice(client *girc.Client, event girc.Event) {
|
||||
if strings.Contains(event.String(), "This nickname is registered") && event.Source.Name == b.GetString("NickServNick") {
|
||||
b.handleNickServ()
|
||||
} else {
|
||||
b.handlePrivMsg(client, event)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Birc) handleOther(client *girc.Client, event girc.Event) {
|
||||
if b.GetInt("DebugLevel") == 1 {
|
||||
if event.Command != "CLIENT_STATE_UPDATED" &&
|
||||
event.Command != "CLIENT_GENERAL_UPDATED" {
|
||||
b.Log.Debugf("%#v", event.String())
|
||||
}
|
||||
return
|
||||
}
|
||||
switch event.Command {
|
||||
case "372", "375", "376", "250", "251", "252", "253", "254", "255", "265", "266", "002", "003", "004", "005":
|
||||
return
|
||||
}
|
||||
b.Log.Debugf("%#v", event.String())
|
||||
}
|
||||
|
||||
func (b *Birc) handleOtherAuth(client *girc.Client, event girc.Event) {
|
||||
b.handleNickServ()
|
||||
b.handleRunCommands()
|
||||
// we are now fully connected
|
||||
// only send on first connection
|
||||
if b.FirstConnection {
|
||||
b.connected <- nil
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Birc) handlePrivMsg(client *girc.Client, event girc.Event) {
|
||||
if b.skipPrivMsg(event) {
|
||||
return
|
||||
}
|
||||
|
||||
rmsg := config.Message{
|
||||
Username: event.Source.Name,
|
||||
Channel: strings.ToLower(event.Params[0]),
|
||||
Account: b.Account,
|
||||
UserID: event.Source.Ident + "@" + event.Source.Host,
|
||||
}
|
||||
|
||||
b.Log.Debugf("== Receiving PRIVMSG: %s %s %#v", event.Source.Name, event.Last(), event)
|
||||
|
||||
// set action event
|
||||
if event.IsAction() {
|
||||
rmsg.Event = config.EventUserAction
|
||||
}
|
||||
|
||||
// set NOTICE event
|
||||
if event.Command == "NOTICE" {
|
||||
rmsg.Event = config.EventNoticeIRC
|
||||
}
|
||||
|
||||
// strip action, we made an event if it was an action
|
||||
rmsg.Text += event.StripAction()
|
||||
|
||||
// start detecting the charset
|
||||
mycharset := b.GetString("Charset")
|
||||
if mycharset == "" {
|
||||
// detect what were sending so that we convert it to utf-8
|
||||
detector := chardet.NewTextDetector()
|
||||
result, err := detector.DetectBest([]byte(rmsg.Text))
|
||||
if err != nil {
|
||||
b.Log.Infof("detection failed for rmsg.Text: %#v", rmsg.Text)
|
||||
return
|
||||
}
|
||||
b.Log.Debugf("detected %s confidence %#v", result.Charset, result.Confidence)
|
||||
mycharset = result.Charset
|
||||
// if we're not sure, just pick ISO-8859-1
|
||||
if result.Confidence < 80 {
|
||||
mycharset = "ISO-8859-1"
|
||||
}
|
||||
}
|
||||
switch mycharset {
|
||||
case "gbk", "gb18030", "gb2312", "big5", "euc-kr", "euc-jp", "shift-jis", "iso-2022-jp":
|
||||
rmsg.Text = ic.ConvertString("utf-8", b.GetString("Charset"), rmsg.Text)
|
||||
default:
|
||||
r, err := charset.NewReader(mycharset, strings.NewReader(rmsg.Text))
|
||||
if err != nil {
|
||||
b.Log.Errorf("charset to utf-8 conversion failed: %s", err)
|
||||
return
|
||||
}
|
||||
output, _ := ioutil.ReadAll(r)
|
||||
rmsg.Text = string(output)
|
||||
}
|
||||
|
||||
b.Log.Debugf("<= Sending message from %s on %s to gateway", event.Params[0], b.Account)
|
||||
b.Remote <- rmsg
|
||||
}
|
||||
|
||||
func (b *Birc) handleRunCommands() {
|
||||
for _, cmd := range b.GetStringSlice("RunCommands") {
|
||||
if err := b.i.Cmd.SendRaw(cmd); err != nil {
|
||||
b.Log.Errorf("RunCommands %s failed: %s", cmd, err)
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Birc) handleTopicWhoTime(client *girc.Client, event girc.Event) {
|
||||
parts := strings.Split(event.Params[2], "!")
|
||||
t, err := strconv.ParseInt(event.Params[3], 10, 64)
|
||||
if err != nil {
|
||||
b.Log.Errorf("Invalid time stamp: %s", event.Params[3])
|
||||
}
|
||||
user := parts[0]
|
||||
if len(parts) > 1 {
|
||||
user += " [" + parts[1] + "]"
|
||||
}
|
||||
b.Log.Debugf("%s: Topic set by %s [%s]", event.Command, user, time.Unix(t, 0))
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
package birc
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
/*
|
||||
func tableformatter(nicks []string, nicksPerRow int, continued bool) string {
|
||||
result := "|IRC users"
|
||||
if continued {
|
||||
result = "|(continued)"
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
for j := 1; j <= nicksPerRow && j <= len(nicks); j++ {
|
||||
if i == 0 {
|
||||
result += "|"
|
||||
} else {
|
||||
result += ":-|"
|
||||
}
|
||||
}
|
||||
result += "\r\n|"
|
||||
}
|
||||
result += nicks[0] + "|"
|
||||
for i := 1; i < len(nicks); i++ {
|
||||
if i%nicksPerRow == 0 {
|
||||
result += "\r\n|" + nicks[i] + "|"
|
||||
} else {
|
||||
result += nicks[i] + "|"
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
*/
|
||||
|
||||
func plainformatter(nicks []string, nicksPerRow int) string {
|
||||
return strings.Join(nicks, ", ") + " currently on IRC"
|
||||
}
|
||||
|
||||
func IsMarkup(message string) bool {
|
||||
switch message[0] {
|
||||
case '|':
|
||||
fallthrough
|
||||
case '#':
|
||||
fallthrough
|
||||
case '_':
|
||||
fallthrough
|
||||
case '*':
|
||||
fallthrough
|
||||
case '~':
|
||||
fallthrough
|
||||
case '-':
|
||||
fallthrough
|
||||
case ':':
|
||||
fallthrough
|
||||
case '>':
|
||||
fallthrough
|
||||
case '=':
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,365 +1,371 @@
|
||||
package birc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"github.com/42wim/go-ircevent"
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
log "github.com/Sirupsen/logrus"
|
||||
"github.com/paulrosania/go-charset/charset"
|
||||
_ "github.com/paulrosania/go-charset/data"
|
||||
"github.com/saintfish/chardet"
|
||||
ircm "github.com/sorcix/irc"
|
||||
"io"
|
||||
"hash/crc32"
|
||||
"io/ioutil"
|
||||
"regexp"
|
||||
"net"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/42wim/matterbridge/bridge"
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
"github.com/42wim/matterbridge/bridge/helper"
|
||||
"github.com/lrstanley/girc"
|
||||
stripmd "github.com/writeas/go-strip-markdown"
|
||||
|
||||
// We need to import the 'data' package as an implicit dependency.
|
||||
// See: https://godoc.org/github.com/paulrosania/go-charset/charset
|
||||
_ "github.com/paulrosania/go-charset/data"
|
||||
)
|
||||
|
||||
type Birc struct {
|
||||
i *irc.Connection
|
||||
Nick string
|
||||
names map[string][]string
|
||||
Config *config.Protocol
|
||||
Remote chan config.Message
|
||||
connected chan struct{}
|
||||
Local chan config.Message // local queue for flood control
|
||||
Account string
|
||||
FirstConnection bool
|
||||
i *girc.Client
|
||||
Nick string
|
||||
names map[string][]string
|
||||
connected chan error
|
||||
Local chan config.Message // local queue for flood control
|
||||
FirstConnection, authDone bool
|
||||
MessageDelay, MessageQueue, MessageLength int
|
||||
channels map[string]bool
|
||||
|
||||
*bridge.Config
|
||||
}
|
||||
|
||||
var flog *log.Entry
|
||||
var protocol = "irc"
|
||||
|
||||
func init() {
|
||||
flog = log.WithFields(log.Fields{"module": protocol})
|
||||
}
|
||||
|
||||
func New(cfg config.Protocol, account string, c chan config.Message) *Birc {
|
||||
func New(cfg *bridge.Config) bridge.Bridger {
|
||||
b := &Birc{}
|
||||
b.Config = &cfg
|
||||
b.Nick = b.Config.Nick
|
||||
b.Remote = c
|
||||
b.Config = cfg
|
||||
b.Nick = b.GetString("Nick")
|
||||
b.names = make(map[string][]string)
|
||||
b.Account = account
|
||||
b.connected = make(chan struct{})
|
||||
if b.Config.MessageDelay == 0 {
|
||||
b.Config.MessageDelay = 1300
|
||||
b.connected = make(chan error)
|
||||
b.channels = make(map[string]bool)
|
||||
|
||||
if b.GetInt("MessageDelay") == 0 {
|
||||
b.MessageDelay = 1300
|
||||
} else {
|
||||
b.MessageDelay = b.GetInt("MessageDelay")
|
||||
}
|
||||
if b.Config.MessageQueue == 0 {
|
||||
b.Config.MessageQueue = 30
|
||||
if b.GetInt("MessageQueue") == 0 {
|
||||
b.MessageQueue = 30
|
||||
} else {
|
||||
b.MessageQueue = b.GetInt("MessageQueue")
|
||||
}
|
||||
if b.Config.MessageLength == 0 {
|
||||
b.Config.MessageLength = 400
|
||||
if b.GetInt("MessageLength") == 0 {
|
||||
b.MessageLength = 400
|
||||
} else {
|
||||
b.MessageLength = b.GetInt("MessageLength")
|
||||
}
|
||||
b.FirstConnection = true
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *Birc) Command(msg *config.Message) string {
|
||||
switch msg.Text {
|
||||
case "!users":
|
||||
b.i.AddCallback(ircm.RPL_NAMREPLY, b.storeNames)
|
||||
b.i.AddCallback(ircm.RPL_ENDOFNAMES, b.endNames)
|
||||
b.i.SendRaw("NAMES " + msg.Channel)
|
||||
if msg.Text == "!users" {
|
||||
b.i.Handlers.Add(girc.RPL_NAMREPLY, b.storeNames)
|
||||
b.i.Handlers.Add(girc.RPL_ENDOFNAMES, b.endNames)
|
||||
b.i.Cmd.SendRaw("NAMES " + msg.Channel) //nolint:errcheck
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (b *Birc) Connect() error {
|
||||
b.Local = make(chan config.Message, b.Config.MessageQueue+10)
|
||||
flog.Infof("Connecting %s", b.Config.Server)
|
||||
i := irc.IRC(b.Config.Nick, b.Config.Nick)
|
||||
if log.GetLevel() == log.DebugLevel {
|
||||
i.Debug = true
|
||||
}
|
||||
i.UseTLS = b.Config.UseTLS
|
||||
i.UseSASL = b.Config.UseSASL
|
||||
i.SASLLogin = b.Config.NickServNick
|
||||
i.SASLPassword = b.Config.NickServPassword
|
||||
i.TLSConfig = &tls.Config{InsecureSkipVerify: b.Config.SkipTLSVerify}
|
||||
i.KeepAlive = time.Minute
|
||||
i.PingFreq = time.Minute
|
||||
if b.Config.Password != "" {
|
||||
i.Password = b.Config.Password
|
||||
}
|
||||
i.AddCallback(ircm.RPL_WELCOME, b.handleNewConnection)
|
||||
i.AddCallback(ircm.RPL_ENDOFMOTD, b.handleOtherAuth)
|
||||
err := i.Connect(b.Config.Server)
|
||||
b.Local = make(chan config.Message, b.MessageQueue+10)
|
||||
b.Log.Infof("Connecting %s", b.GetString("Server"))
|
||||
|
||||
i, err := b.getClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b.i = i
|
||||
select {
|
||||
case <-b.connected:
|
||||
flog.Info("Connection succeeded")
|
||||
case <-time.After(time.Second * 30):
|
||||
return fmt.Errorf("connection timed out")
|
||||
|
||||
if b.GetBool("UseSASL") {
|
||||
i.Config.SASL = &girc.SASLPlain{
|
||||
User: b.GetString("NickServNick"),
|
||||
Pass: b.GetString("NickServPassword"),
|
||||
}
|
||||
}
|
||||
|
||||
i.Handlers.Add(girc.RPL_WELCOME, b.handleNewConnection)
|
||||
i.Handlers.Add(girc.RPL_ENDOFMOTD, b.handleOtherAuth)
|
||||
i.Handlers.Add(girc.ERR_NOMOTD, b.handleOtherAuth)
|
||||
i.Handlers.Add(girc.ALL_EVENTS, b.handleOther)
|
||||
b.i = i
|
||||
|
||||
go b.doConnect()
|
||||
|
||||
err = <-b.connected
|
||||
if err != nil {
|
||||
return fmt.Errorf("connection failed %s", err)
|
||||
}
|
||||
b.Log.Info("Connection succeeded")
|
||||
b.FirstConnection = false
|
||||
if b.GetInt("DebugLevel") == 0 {
|
||||
i.Handlers.Clear(girc.ALL_EVENTS)
|
||||
}
|
||||
i.Debug = false
|
||||
// clear on reconnects
|
||||
i.ClearCallback(ircm.RPL_WELCOME)
|
||||
i.AddCallback(ircm.RPL_WELCOME, func(event *irc.Event) {
|
||||
b.Remote <- config.Message{Username: "system", Text: "rejoin", Channel: "", Account: b.Account, Event: config.EVENT_REJOIN_CHANNELS}
|
||||
// set our correct nick on reconnect if necessary
|
||||
b.Nick = event.Nick
|
||||
})
|
||||
go i.Loop()
|
||||
go b.doSend()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Birc) Disconnect() error {
|
||||
//b.i.Disconnect()
|
||||
b.i.Close()
|
||||
close(b.Local)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Birc) JoinChannel(channel config.ChannelInfo) error {
|
||||
b.channels[channel.Name] = true
|
||||
// need to check if we have nickserv auth done before joining channels
|
||||
for {
|
||||
if b.authDone {
|
||||
break
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
if channel.Options.Key != "" {
|
||||
flog.Debugf("using key %s for channel %s", channel.Options.Key, channel.Name)
|
||||
b.i.Join(channel.Name + " " + channel.Options.Key)
|
||||
b.Log.Debugf("using key %s for channel %s", channel.Options.Key, channel.Name)
|
||||
b.i.Cmd.JoinKey(channel.Name, channel.Options.Key)
|
||||
} else {
|
||||
b.i.Join(channel.Name)
|
||||
b.i.Cmd.Join(channel.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Birc) Send(msg config.Message) (string, error) {
|
||||
// ignore delete messages
|
||||
if msg.Event == config.EVENT_MSG_DELETE {
|
||||
if msg.Event == config.EventMsgDelete {
|
||||
return "", nil
|
||||
}
|
||||
flog.Debugf("Receiving %#v", msg)
|
||||
|
||||
b.Log.Debugf("=> Receiving %#v", msg)
|
||||
|
||||
// we can be in between reconnects #385
|
||||
if !b.i.IsConnected() {
|
||||
b.Log.Error("Not connected to server, dropping message")
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Execute a command
|
||||
if strings.HasPrefix(msg.Text, "!") {
|
||||
b.Command(&msg)
|
||||
}
|
||||
|
||||
if b.Config.Charset != "" {
|
||||
buf := new(bytes.Buffer)
|
||||
w, err := charset.NewWriter(b.Config.Charset, buf)
|
||||
if err != nil {
|
||||
flog.Errorf("charset from utf-8 conversion failed: %s", err)
|
||||
return "", err
|
||||
}
|
||||
fmt.Fprintf(w, msg.Text)
|
||||
w.Close()
|
||||
msg.Text = buf.String()
|
||||
// convert to specified charset
|
||||
if err := b.handleCharset(&msg); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for _, text := range strings.Split(msg.Text, "\n") {
|
||||
if len(text) > b.Config.MessageLength {
|
||||
text = text[:b.Config.MessageLength] + " <message clipped>"
|
||||
}
|
||||
if len(b.Local) < b.Config.MessageQueue {
|
||||
if len(b.Local) == b.Config.MessageQueue-1 {
|
||||
text = text + " <message clipped>"
|
||||
}
|
||||
b.Local <- config.Message{Text: text, Username: msg.Username, Channel: msg.Channel, Event: msg.Event}
|
||||
} else {
|
||||
flog.Debugf("flooding, dropping message (queue at %d)", len(b.Local))
|
||||
// handle files, return if we're done here
|
||||
if ok := b.handleFiles(&msg); ok {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
var msgLines []string
|
||||
if b.GetBool("StripMarkdown") {
|
||||
msg.Text = stripmd.Strip(msg.Text)
|
||||
}
|
||||
|
||||
if b.GetBool("MessageSplit") {
|
||||
msgLines = helper.GetSubLines(msg.Text, b.MessageLength)
|
||||
} else {
|
||||
msgLines = helper.GetSubLines(msg.Text, 0)
|
||||
}
|
||||
for i := range msgLines {
|
||||
if len(b.Local) >= b.MessageQueue {
|
||||
b.Log.Debugf("flooding, dropping message (queue at %d)", len(b.Local))
|
||||
return "", nil
|
||||
}
|
||||
|
||||
msg.Text = msgLines[i]
|
||||
b.Local <- msg
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (b *Birc) doConnect() {
|
||||
for {
|
||||
if err := b.i.Connect(); err != nil {
|
||||
b.Log.Errorf("disconnect: error: %s", err)
|
||||
if b.FirstConnection {
|
||||
b.connected <- err
|
||||
return
|
||||
}
|
||||
} else {
|
||||
b.Log.Info("disconnect: client requested quit")
|
||||
}
|
||||
b.Log.Info("reconnecting in 30 seconds...")
|
||||
time.Sleep(30 * time.Second)
|
||||
b.i.Handlers.Clear(girc.RPL_WELCOME)
|
||||
b.i.Handlers.Add(girc.RPL_WELCOME, func(client *girc.Client, event girc.Event) {
|
||||
b.Remote <- config.Message{Username: "system", Text: "rejoin", Channel: "", Account: b.Account, Event: config.EventRejoinChannels}
|
||||
// set our correct nick on reconnect if necessary
|
||||
b.Nick = event.Source.Name
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitize nicks for RELAYMSG: replace IRC characters with special meanings with "-"
|
||||
func sanitizeNick(nick string) string {
|
||||
sanitize := func(r rune) rune {
|
||||
if strings.ContainsRune("!+%@&#$:'\"?*,. ", r) {
|
||||
return '-'
|
||||
}
|
||||
return r
|
||||
}
|
||||
return strings.Map(sanitize, nick)
|
||||
}
|
||||
|
||||
func (b *Birc) doSend() {
|
||||
rate := time.Millisecond * time.Duration(b.Config.MessageDelay)
|
||||
rate := time.Millisecond * time.Duration(b.MessageDelay)
|
||||
throttle := time.NewTicker(rate)
|
||||
for msg := range b.Local {
|
||||
<-throttle.C
|
||||
if msg.Event == config.EVENT_USER_ACTION {
|
||||
b.i.Action(msg.Channel, msg.Username+msg.Text)
|
||||
username := msg.Username
|
||||
// Optional support for the proposed RELAYMSG extension, described at
|
||||
// https://github.com/jlu5/ircv3-specifications/blob/master/extensions/relaymsg.md
|
||||
// nolint:nestif
|
||||
if (b.i.HasCapability("overdrivenetworks.com/relaymsg") || b.i.HasCapability("draft/relaymsg")) &&
|
||||
b.GetBool("UseRelayMsg") {
|
||||
username = sanitizeNick(username)
|
||||
text := msg.Text
|
||||
|
||||
// Work around girc chomping leading commas on single word messages?
|
||||
if strings.HasPrefix(text, ":") && !strings.ContainsRune(text, ' ') {
|
||||
text = ":" + text
|
||||
}
|
||||
|
||||
if msg.Event == config.EventUserAction {
|
||||
b.i.Cmd.SendRawf("RELAYMSG %s %s :\x01ACTION %s\x01", msg.Channel, username, text) //nolint:errcheck
|
||||
} else {
|
||||
b.Log.Debugf("Sending RELAYMSG to channel %s: nick=%s", msg.Channel, username)
|
||||
b.i.Cmd.SendRawf("RELAYMSG %s %s :%s", msg.Channel, username, text) //nolint:errcheck
|
||||
}
|
||||
} else {
|
||||
b.i.Privmsg(msg.Channel, msg.Username+msg.Text)
|
||||
if b.GetBool("Colornicks") {
|
||||
checksum := crc32.ChecksumIEEE([]byte(msg.Username))
|
||||
colorCode := checksum%14 + 2 // quick fix - prevent white or black color codes
|
||||
username = fmt.Sprintf("\x03%02d%s\x0F", colorCode, msg.Username)
|
||||
}
|
||||
switch msg.Event {
|
||||
case config.EventUserAction:
|
||||
b.i.Cmd.Action(msg.Channel, username+msg.Text)
|
||||
case config.EventNoticeIRC:
|
||||
b.Log.Debugf("Sending notice to channel %s", msg.Channel)
|
||||
b.i.Cmd.Notice(msg.Channel, username+msg.Text)
|
||||
default:
|
||||
b.Log.Debugf("Sending to channel %s", msg.Channel)
|
||||
b.i.Cmd.Message(msg.Channel, username+msg.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Birc) endNames(event *irc.Event) {
|
||||
channel := event.Arguments[1]
|
||||
// validateInput validates the server/port/nick configuration. Returns a *girc.Client if successful
|
||||
func (b *Birc) getClient() (*girc.Client, error) {
|
||||
server, portstr, err := net.SplitHostPort(b.GetString("Server"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
port, err := strconv.Atoi(portstr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// fix strict user handling of girc
|
||||
user := b.GetString("Nick")
|
||||
for !girc.IsValidUser(user) {
|
||||
if len(user) == 1 || len(user) == 0 {
|
||||
user = "matterbridge"
|
||||
break
|
||||
}
|
||||
user = user[1:]
|
||||
}
|
||||
|
||||
debug := ioutil.Discard
|
||||
if b.GetInt("DebugLevel") == 2 {
|
||||
debug = b.Log.Writer()
|
||||
}
|
||||
|
||||
pingDelay, err := time.ParseDuration(b.GetString("pingdelay"))
|
||||
if err != nil || pingDelay == 0 {
|
||||
pingDelay = time.Minute
|
||||
}
|
||||
|
||||
b.Log.Debugf("setting pingdelay to %s", pingDelay)
|
||||
|
||||
i := girc.New(girc.Config{
|
||||
Server: server,
|
||||
ServerPass: b.GetString("Password"),
|
||||
Port: port,
|
||||
Nick: b.GetString("Nick"),
|
||||
User: user,
|
||||
Name: b.GetString("Nick"),
|
||||
SSL: b.GetBool("UseTLS"),
|
||||
TLSConfig: &tls.Config{InsecureSkipVerify: b.GetBool("SkipTLSVerify"), ServerName: server}, //nolint:gosec
|
||||
PingDelay: pingDelay,
|
||||
// skip gIRC internal rate limiting, since we have our own throttling
|
||||
AllowFlood: true,
|
||||
Debug: debug,
|
||||
SupportedCaps: map[string][]string{"overdrivenetworks.com/relaymsg": nil, "draft/relaymsg": nil},
|
||||
})
|
||||
return i, nil
|
||||
}
|
||||
|
||||
func (b *Birc) endNames(client *girc.Client, event girc.Event) {
|
||||
channel := event.Params[1]
|
||||
sort.Strings(b.names[channel])
|
||||
maxNamesPerPost := (300 / b.nicksPerRow()) * b.nicksPerRow()
|
||||
continued := false
|
||||
for len(b.names[channel]) > maxNamesPerPost {
|
||||
b.Remote <- config.Message{Username: b.Nick, Text: b.formatnicks(b.names[channel][0:maxNamesPerPost], continued),
|
||||
b.Remote <- config.Message{Username: b.Nick, Text: b.formatnicks(b.names[channel][0:maxNamesPerPost]),
|
||||
Channel: channel, Account: b.Account}
|
||||
b.names[channel] = b.names[channel][maxNamesPerPost:]
|
||||
continued = true
|
||||
}
|
||||
b.Remote <- config.Message{Username: b.Nick, Text: b.formatnicks(b.names[channel], continued),
|
||||
b.Remote <- config.Message{Username: b.Nick, Text: b.formatnicks(b.names[channel]),
|
||||
Channel: channel, Account: b.Account}
|
||||
b.names[channel] = nil
|
||||
b.i.ClearCallback(ircm.RPL_NAMREPLY)
|
||||
b.i.ClearCallback(ircm.RPL_ENDOFNAMES)
|
||||
b.i.Handlers.Clear(girc.RPL_NAMREPLY)
|
||||
b.i.Handlers.Clear(girc.RPL_ENDOFNAMES)
|
||||
}
|
||||
|
||||
func (b *Birc) handleNewConnection(event *irc.Event) {
|
||||
flog.Debug("Registering callbacks")
|
||||
i := b.i
|
||||
b.Nick = event.Arguments[0]
|
||||
i.AddCallback("PRIVMSG", b.handlePrivMsg)
|
||||
i.AddCallback("CTCP_ACTION", b.handlePrivMsg)
|
||||
i.AddCallback(ircm.RPL_TOPICWHOTIME, b.handleTopicWhoTime)
|
||||
i.AddCallback(ircm.NOTICE, b.handleNotice)
|
||||
//i.AddCallback(ircm.RPL_MYINFO, func(e *irc.Event) { flog.Infof("%s: %s", e.Code, strings.Join(e.Arguments[1:], " ")) })
|
||||
i.AddCallback("PING", func(e *irc.Event) {
|
||||
i.SendRaw("PONG :" + e.Message())
|
||||
flog.Debugf("PING/PONG")
|
||||
})
|
||||
i.AddCallback("JOIN", b.handleJoinPart)
|
||||
i.AddCallback("PART", b.handleJoinPart)
|
||||
i.AddCallback("QUIT", b.handleJoinPart)
|
||||
i.AddCallback("KICK", b.handleJoinPart)
|
||||
i.AddCallback("*", b.handleOther)
|
||||
// we are now fully connected
|
||||
b.connected <- struct{}{}
|
||||
}
|
||||
|
||||
func (b *Birc) handleJoinPart(event *irc.Event) {
|
||||
channel := event.Arguments[0]
|
||||
if event.Code == "KICK" {
|
||||
flog.Infof("Got kicked from %s by %s", channel, event.Nick)
|
||||
b.Remote <- config.Message{Username: "system", Text: "rejoin", Channel: channel, Account: b.Account, Event: config.EVENT_REJOIN_CHANNELS}
|
||||
return
|
||||
}
|
||||
if event.Code == "QUIT" {
|
||||
if event.Nick == b.Nick && strings.Contains(event.Raw, "Ping timeout") {
|
||||
flog.Infof("%s reconnecting ..", b.Account)
|
||||
b.Remote <- config.Message{Username: "system", Text: "reconnect", Channel: channel, Account: b.Account, Event: config.EVENT_FAILURE}
|
||||
return
|
||||
}
|
||||
}
|
||||
if event.Nick != b.Nick {
|
||||
flog.Debugf("Sending JOIN_LEAVE event from %s to gateway", b.Account)
|
||||
b.Remote <- config.Message{Username: "system", Text: event.Nick + " " + strings.ToLower(event.Code) + "s", Channel: channel, Account: b.Account, Event: config.EVENT_JOIN_LEAVE}
|
||||
return
|
||||
}
|
||||
flog.Debugf("handle %#v", event)
|
||||
}
|
||||
|
||||
func (b *Birc) handleNotice(event *irc.Event) {
|
||||
if strings.Contains(event.Message(), "This nickname is registered") && event.Nick == b.Config.NickServNick {
|
||||
b.i.Privmsg(b.Config.NickServNick, "IDENTIFY "+b.Config.NickServPassword)
|
||||
} else {
|
||||
b.handlePrivMsg(event)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Birc) handleOther(event *irc.Event) {
|
||||
switch event.Code {
|
||||
case "372", "375", "376", "250", "251", "252", "253", "254", "255", "265", "266", "002", "003", "004", "005":
|
||||
return
|
||||
}
|
||||
flog.Debugf("%#v", event.Raw)
|
||||
}
|
||||
|
||||
func (b *Birc) handleOtherAuth(event *irc.Event) {
|
||||
if strings.EqualFold(b.Config.NickServNick, "Q@CServe.quakenet.org") {
|
||||
flog.Debugf("Authenticating %s against %s", b.Config.NickServUsername, b.Config.NickServNick)
|
||||
b.i.Privmsg(b.Config.NickServNick, "AUTH "+b.Config.NickServUsername+" "+b.Config.NickServPassword)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Birc) handlePrivMsg(event *irc.Event) {
|
||||
func (b *Birc) skipPrivMsg(event girc.Event) bool {
|
||||
// Our nick can be changed
|
||||
b.Nick = b.i.GetNick()
|
||||
|
||||
// freenode doesn't send 001 as first reply
|
||||
if event.Code == "NOTICE" {
|
||||
return
|
||||
if event.Command == "NOTICE" && len(event.Params) != 2 {
|
||||
return true
|
||||
}
|
||||
// don't forward queries to the bot
|
||||
if event.Arguments[0] == b.Nick {
|
||||
return
|
||||
if event.Params[0] == b.Nick {
|
||||
return true
|
||||
}
|
||||
// don't forward message from ourself
|
||||
if event.Nick == b.Nick {
|
||||
return
|
||||
if event.Source.Name == b.Nick {
|
||||
return true
|
||||
}
|
||||
rmsg := config.Message{Username: event.Nick, Channel: event.Arguments[0], Account: b.Account, UserID: event.User + "@" + event.Host}
|
||||
flog.Debugf("handlePrivMsg() %s %s %#v", event.Nick, event.Message(), event)
|
||||
msg := ""
|
||||
if event.Code == "CTCP_ACTION" {
|
||||
// msg = event.Nick + " "
|
||||
rmsg.Event = config.EVENT_USER_ACTION
|
||||
// don't forward messages we sent via RELAYMSG
|
||||
if relayedNick, ok := event.Tags.Get("draft/relaymsg"); ok && relayedNick == b.Nick {
|
||||
return true
|
||||
}
|
||||
msg += event.Message()
|
||||
// strip IRC colors
|
||||
re := regexp.MustCompile(`[[:cntrl:]](\d+,|)\d+`)
|
||||
msg = re.ReplaceAllString(msg, "")
|
||||
|
||||
var r io.Reader
|
||||
var err error
|
||||
mycharset := b.Config.Charset
|
||||
if mycharset == "" {
|
||||
// detect what were sending so that we convert it to utf-8
|
||||
detector := chardet.NewTextDetector()
|
||||
result, err := detector.DetectBest([]byte(msg))
|
||||
if err != nil {
|
||||
flog.Infof("detection failed for msg: %#v", msg)
|
||||
return
|
||||
}
|
||||
flog.Debugf("detected %s confidence %#v", result.Charset, result.Confidence)
|
||||
mycharset = result.Charset
|
||||
// if we're not sure, just pick ISO-8859-1
|
||||
if result.Confidence < 80 {
|
||||
mycharset = "ISO-8859-1"
|
||||
}
|
||||
// This is the old name of the cap sent in spoofed messages; I've kept this in
|
||||
// for compatibility reasons
|
||||
if relayedNick, ok := event.Tags.Get("relaymsg"); ok && relayedNick == b.Nick {
|
||||
return true
|
||||
}
|
||||
r, err = charset.NewReader(mycharset, strings.NewReader(msg))
|
||||
if err != nil {
|
||||
flog.Errorf("charset to utf-8 conversion failed: %s", err)
|
||||
return
|
||||
}
|
||||
output, _ := ioutil.ReadAll(r)
|
||||
msg = string(output)
|
||||
|
||||
flog.Debugf("Sending message from %s on %s to gateway", event.Arguments[0], b.Account)
|
||||
rmsg.Text = msg
|
||||
b.Remote <- rmsg
|
||||
}
|
||||
|
||||
func (b *Birc) handleTopicWhoTime(event *irc.Event) {
|
||||
parts := strings.Split(event.Arguments[2], "!")
|
||||
t, err := strconv.ParseInt(event.Arguments[3], 10, 64)
|
||||
if err != nil {
|
||||
flog.Errorf("Invalid time stamp: %s", event.Arguments[3])
|
||||
}
|
||||
user := parts[0]
|
||||
if len(parts) > 1 {
|
||||
user += " [" + parts[1] + "]"
|
||||
}
|
||||
flog.Debugf("%s: Topic set by %s [%s]", event.Code, user, time.Unix(t, 0))
|
||||
return false
|
||||
}
|
||||
|
||||
func (b *Birc) nicksPerRow() int {
|
||||
return 4
|
||||
/*
|
||||
if b.Config.Mattermost.NicksPerRow < 1 {
|
||||
return 4
|
||||
}
|
||||
return b.Config.Mattermost.NicksPerRow
|
||||
*/
|
||||
}
|
||||
|
||||
func (b *Birc) storeNames(event *irc.Event) {
|
||||
channel := event.Arguments[2]
|
||||
func (b *Birc) storeNames(client *girc.Client, event girc.Event) {
|
||||
channel := event.Params[2]
|
||||
b.names[channel] = append(
|
||||
b.names[channel],
|
||||
strings.Split(strings.TrimSpace(event.Message()), " ")...)
|
||||
strings.Split(strings.TrimSpace(event.Last()), " ")...)
|
||||
}
|
||||
|
||||
func (b *Birc) formatnicks(nicks []string, continued bool) string {
|
||||
return plainformatter(nicks, b.nicksPerRow())
|
||||
/*
|
||||
switch b.Config.Mattermost.NickFormatter {
|
||||
case "table":
|
||||
return tableformatter(nicks, b.nicksPerRow(), continued)
|
||||
default:
|
||||
return plainformatter(nicks, b.nicksPerRow())
|
||||
}
|
||||
*/
|
||||
func (b *Birc) formatnicks(nicks []string) string {
|
||||
return strings.Join(nicks, ", ") + " currently on IRC"
|
||||
}
|
||||
|
||||
59
bridge/keybase/handlers.go
Normal file
59
bridge/keybase/handlers.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package bkeybase
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
"github.com/keybase/go-keybase-chat-bot/kbchat/types/chat1"
|
||||
)
|
||||
|
||||
func (b *Bkeybase) handleKeybase() {
|
||||
sub, err := b.kbc.ListenForNewTextMessages()
|
||||
if err != nil {
|
||||
b.Log.Errorf("Error listening: %s", err.Error())
|
||||
}
|
||||
|
||||
go func() {
|
||||
for {
|
||||
msg, err := sub.Read()
|
||||
if err != nil {
|
||||
b.Log.Errorf("failed to read message: %s", err.Error())
|
||||
}
|
||||
|
||||
if msg.Message.Content.TypeName != "text" {
|
||||
continue
|
||||
}
|
||||
|
||||
if msg.Message.Sender.Username == b.kbc.GetUsername() {
|
||||
continue
|
||||
}
|
||||
|
||||
b.handleMessage(msg.Message)
|
||||
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (b *Bkeybase) handleMessage(msg chat1.MsgSummary) {
|
||||
b.Log.Debugf("== Receiving event: %#v", msg)
|
||||
if msg.Channel.TopicName != b.channel || msg.Channel.Name != b.team {
|
||||
return
|
||||
}
|
||||
|
||||
if msg.Sender.Username != b.kbc.GetUsername() {
|
||||
|
||||
// TODO download avatar
|
||||
|
||||
// Create our message
|
||||
rmsg := config.Message{Username: msg.Sender.Username, Text: msg.Content.Text.Body, UserID: string(msg.Sender.Uid), Channel: msg.Channel.TopicName, ID: strconv.Itoa(int(msg.Id)), Account: b.Account}
|
||||
|
||||
// Text must be a string
|
||||
if msg.Content.TypeName != "text" {
|
||||
b.Log.Errorf("message is not text")
|
||||
return
|
||||
}
|
||||
|
||||
b.Log.Debugf("<= Sending message from %s on %s to gateway", msg.Sender.Username, msg.Channel.Name)
|
||||
b.Remote <- rmsg
|
||||
}
|
||||
}
|
||||
106
bridge/keybase/keybase.go
Normal file
106
bridge/keybase/keybase.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package bkeybase
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"github.com/42wim/matterbridge/bridge"
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
"github.com/keybase/go-keybase-chat-bot/kbchat"
|
||||
)
|
||||
|
||||
// Bkeybase bridge structure
|
||||
type Bkeybase struct {
|
||||
kbc *kbchat.API
|
||||
user string
|
||||
channel string
|
||||
team string
|
||||
*bridge.Config
|
||||
}
|
||||
|
||||
// New initializes Bkeybase object and sets team
|
||||
func New(cfg *bridge.Config) bridge.Bridger {
|
||||
b := &Bkeybase{Config: cfg}
|
||||
b.team = b.Config.GetString("Team")
|
||||
return b
|
||||
}
|
||||
|
||||
// Connect starts keybase API and listener loop
|
||||
func (b *Bkeybase) Connect() error {
|
||||
var err error
|
||||
b.Log.Infof("Connecting %s", b.GetString("Team"))
|
||||
|
||||
// use default keybase location (`keybase`)
|
||||
b.kbc, err = kbchat.Start(kbchat.RunOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b.user = b.kbc.GetUsername()
|
||||
b.Log.Info("Connection succeeded")
|
||||
go b.handleKeybase()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Disconnect doesn't do anything for now
|
||||
func (b *Bkeybase) Disconnect() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// JoinChannel sets channel name in struct
|
||||
func (b *Bkeybase) JoinChannel(channel config.ChannelInfo) error {
|
||||
if _, err := b.kbc.JoinChannel(b.team, channel.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
b.channel = channel.Name
|
||||
return nil
|
||||
}
|
||||
|
||||
// Send receives bridge messages and sends them to Keybase chat room
|
||||
func (b *Bkeybase) Send(msg config.Message) (string, error) {
|
||||
b.Log.Debugf("=> Receiving %#v", msg)
|
||||
|
||||
// Handle /me events
|
||||
if msg.Event == config.EventUserAction {
|
||||
msg.Text = "_" + msg.Text + "_"
|
||||
}
|
||||
|
||||
// Delete message if we have an ID
|
||||
// Delete message not supported by keybase go library yet
|
||||
|
||||
// Edit message if we have an ID
|
||||
// kbchat lib does not support message editing yet
|
||||
|
||||
if len(msg.Extra["file"]) > 0 {
|
||||
// Upload a file
|
||||
dir, err := ioutil.TempDir("", "matterbridge")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
for _, f := range msg.Extra["file"] {
|
||||
fname := f.(config.FileInfo).Name
|
||||
fdata := *f.(config.FileInfo).Data
|
||||
fcaption := f.(config.FileInfo).Comment
|
||||
fpath := filepath.Join(dir, fname)
|
||||
|
||||
if err = ioutil.WriteFile(fpath, fdata, 0600); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
_, _ = b.kbc.SendAttachmentByTeam(b.team, &b.channel, fpath, fcaption)
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Send regular message
|
||||
text := msg.Username + msg.Text
|
||||
resp, err := b.kbc.SendMessageByTeamName(b.team, &b.channel, text)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strconv.Itoa(int(*resp.Result.MessageID)), err
|
||||
}
|
||||
215
bridge/matrix/helpers.go
Normal file
215
bridge/matrix/helpers.go
Normal file
@@ -0,0 +1,215 @@
|
||||
package bmatrix
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
matrix "github.com/matrix-org/gomatrix"
|
||||
)
|
||||
|
||||
func newMatrixUsername(username string) *matrixUsername {
|
||||
mUsername := new(matrixUsername)
|
||||
|
||||
// check if we have a </tag>. if we have, we don't escape HTML. #696
|
||||
if htmlTag.MatchString(username) {
|
||||
mUsername.formatted = username
|
||||
// remove the HTML formatting for beautiful push messages #1188
|
||||
mUsername.plain = htmlReplacementTag.ReplaceAllString(username, "")
|
||||
} else {
|
||||
mUsername.formatted = html.EscapeString(username)
|
||||
mUsername.plain = username
|
||||
}
|
||||
|
||||
return mUsername
|
||||
}
|
||||
|
||||
// getRoomID retrieves a matching room ID from the channel name.
|
||||
func (b *Bmatrix) getRoomID(channel string) string {
|
||||
b.RLock()
|
||||
defer b.RUnlock()
|
||||
for ID, name := range b.RoomMap {
|
||||
if name == channel {
|
||||
return ID
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// interface2Struct marshals and immediately unmarshals an interface.
|
||||
// Useful for converting map[string]interface{} to a struct.
|
||||
func interface2Struct(in interface{}, out interface{}) error {
|
||||
jsonObj, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
return err //nolint:wrapcheck
|
||||
}
|
||||
|
||||
return json.Unmarshal(jsonObj, out)
|
||||
}
|
||||
|
||||
// getDisplayName retrieves the displayName for mxid, querying the homserver if the mxid is not in the cache.
|
||||
func (b *Bmatrix) getDisplayName(mxid string) string {
|
||||
if b.GetBool("UseUserName") {
|
||||
return mxid[1:]
|
||||
}
|
||||
|
||||
b.RLock()
|
||||
if val, present := b.NicknameMap[mxid]; present {
|
||||
b.RUnlock()
|
||||
|
||||
return val.displayName
|
||||
}
|
||||
b.RUnlock()
|
||||
|
||||
displayName, err := b.mc.GetDisplayName(mxid)
|
||||
var httpError *matrix.HTTPError
|
||||
if errors.As(err, &httpError) {
|
||||
b.Log.Warnf("Couldn't retrieve the display name for %s", mxid)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return b.cacheDisplayName(mxid, mxid[1:])
|
||||
}
|
||||
|
||||
return b.cacheDisplayName(mxid, displayName.DisplayName)
|
||||
}
|
||||
|
||||
// cacheDisplayName stores the mapping between a mxid and a display name, to be reused later without performing a query to the homserver.
|
||||
// Note that old entries are cleaned when this function is called.
|
||||
func (b *Bmatrix) cacheDisplayName(mxid string, displayName string) string {
|
||||
now := time.Now()
|
||||
|
||||
// scan to delete old entries, to stop memory usage from becoming too high with old entries.
|
||||
// In addition, we also detect if another user have the same username, and if so, we append their mxids to their usernames to differentiate them.
|
||||
toDelete := []string{}
|
||||
conflict := false
|
||||
|
||||
b.Lock()
|
||||
for mxid, v := range b.NicknameMap {
|
||||
// to prevent username reuse across matrix servers - or even on the same server, append
|
||||
// the mxid to the username when there is a conflict
|
||||
if v.displayName == displayName {
|
||||
conflict = true
|
||||
// TODO: it would be nice to be able to rename previous messages from this user.
|
||||
// The current behavior is that only users with clashing usernames and *that have spoken since the bridge last started* will get their mxids shown, and I don't know if that's the expected behavior.
|
||||
v.displayName = fmt.Sprintf("%s (%s)", displayName, mxid)
|
||||
b.NicknameMap[mxid] = v
|
||||
}
|
||||
|
||||
if now.Sub(v.lastUpdated) > 10*time.Minute {
|
||||
toDelete = append(toDelete, mxid)
|
||||
}
|
||||
}
|
||||
|
||||
if conflict {
|
||||
displayName = fmt.Sprintf("%s (%s)", displayName, mxid)
|
||||
}
|
||||
|
||||
for _, v := range toDelete {
|
||||
delete(b.NicknameMap, v)
|
||||
}
|
||||
|
||||
b.NicknameMap[mxid] = NicknameCacheEntry{
|
||||
displayName: displayName,
|
||||
lastUpdated: now,
|
||||
}
|
||||
b.Unlock()
|
||||
|
||||
return displayName
|
||||
}
|
||||
|
||||
// handleError converts errors into httpError.
|
||||
//nolint:exhaustivestruct
|
||||
func handleError(err error) *httpError {
|
||||
var mErr matrix.HTTPError
|
||||
if !errors.As(err, &mErr) {
|
||||
return &httpError{
|
||||
Err: "not a HTTPError",
|
||||
}
|
||||
}
|
||||
|
||||
var httpErr httpError
|
||||
|
||||
if err := json.Unmarshal(mErr.Contents, &httpErr); err != nil {
|
||||
return &httpError{
|
||||
Err: "unmarshal failed",
|
||||
}
|
||||
}
|
||||
|
||||
return &httpErr
|
||||
}
|
||||
|
||||
func (b *Bmatrix) containsAttachment(content map[string]interface{}) bool {
|
||||
// Skip empty messages
|
||||
if content["msgtype"] == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Only allow image,video or file msgtypes
|
||||
if !(content["msgtype"].(string) == "m.image" ||
|
||||
content["msgtype"].(string) == "m.video" ||
|
||||
content["msgtype"].(string) == "m.file") {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// getAvatarURL returns the avatar URL of the specified sender.
|
||||
func (b *Bmatrix) getAvatarURL(sender string) string {
|
||||
urlPath := b.mc.BuildURL("profile", sender, "avatar_url")
|
||||
|
||||
s := struct {
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
}{}
|
||||
|
||||
err := b.mc.MakeRequest("GET", urlPath, nil, &s)
|
||||
if err != nil {
|
||||
b.Log.Errorf("getAvatarURL failed: %s", err)
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
url := strings.ReplaceAll(s.AvatarURL, "mxc://", b.GetString("Server")+"/_matrix/media/r0/thumbnail/")
|
||||
if url != "" {
|
||||
url += "?width=37&height=37&method=crop"
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
// handleRatelimit handles the ratelimit errors and return if we're ratelimited and the amount of time to sleep
|
||||
func (b *Bmatrix) handleRatelimit(err error) (time.Duration, bool) {
|
||||
httpErr := handleError(err)
|
||||
if httpErr.Errcode != "M_LIMIT_EXCEEDED" {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
b.Log.Debugf("ratelimited: %s", httpErr.Err)
|
||||
b.Log.Infof("getting ratelimited by matrix, sleeping approx %d seconds before retrying", httpErr.RetryAfterMs/1000)
|
||||
|
||||
return time.Duration(httpErr.RetryAfterMs) * time.Millisecond, true
|
||||
}
|
||||
|
||||
// retry function will check if we're ratelimited and retries again when backoff time expired
|
||||
// returns original error if not 429 ratelimit
|
||||
func (b *Bmatrix) retry(f func() error) error {
|
||||
b.rateMutex.Lock()
|
||||
defer b.rateMutex.Unlock()
|
||||
|
||||
for {
|
||||
if err := f(); err != nil {
|
||||
if backoff, ok := b.handleRatelimit(err); ok {
|
||||
time.Sleep(backoff)
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,60 +1,96 @@
|
||||
package bmatrix
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"mime"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/42wim/matterbridge/bridge"
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
log "github.com/Sirupsen/logrus"
|
||||
"github.com/42wim/matterbridge/bridge/helper"
|
||||
matrix "github.com/matrix-org/gomatrix"
|
||||
)
|
||||
|
||||
var (
|
||||
htmlTag = regexp.MustCompile("</.*?>")
|
||||
htmlReplacementTag = regexp.MustCompile("<[^>]*>")
|
||||
)
|
||||
|
||||
type NicknameCacheEntry struct {
|
||||
displayName string
|
||||
lastUpdated time.Time
|
||||
}
|
||||
|
||||
type Bmatrix struct {
|
||||
mc *matrix.Client
|
||||
Config *config.Protocol
|
||||
Remote chan config.Message
|
||||
Account string
|
||||
UserID string
|
||||
RoomMap map[string]string
|
||||
mc *matrix.Client
|
||||
UserID string
|
||||
NicknameMap map[string]NicknameCacheEntry
|
||||
RoomMap map[string]string
|
||||
rateMutex sync.RWMutex
|
||||
sync.RWMutex
|
||||
*bridge.Config
|
||||
}
|
||||
|
||||
var flog *log.Entry
|
||||
var protocol = "matrix"
|
||||
|
||||
func init() {
|
||||
flog = log.WithFields(log.Fields{"module": protocol})
|
||||
type httpError struct {
|
||||
Errcode string `json:"errcode"`
|
||||
Err string `json:"error"`
|
||||
RetryAfterMs int `json:"retry_after_ms"`
|
||||
}
|
||||
|
||||
func New(cfg config.Protocol, account string, c chan config.Message) *Bmatrix {
|
||||
b := &Bmatrix{}
|
||||
type matrixUsername struct {
|
||||
plain string
|
||||
formatted string
|
||||
}
|
||||
|
||||
// SubTextMessage represents the new content of the message in edit messages.
|
||||
type SubTextMessage struct {
|
||||
MsgType string `json:"msgtype"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
// MessageRelation explains how the current message relates to a previous message.
|
||||
// Notably used for message edits.
|
||||
type MessageRelation struct {
|
||||
EventID string `json:"event_id"`
|
||||
Type string `json:"rel_type"`
|
||||
}
|
||||
|
||||
type EditedMessage struct {
|
||||
NewContent SubTextMessage `json:"m.new_content"`
|
||||
RelatedTo MessageRelation `json:"m.relates_to"`
|
||||
matrix.TextMessage
|
||||
}
|
||||
|
||||
func New(cfg *bridge.Config) bridge.Bridger {
|
||||
b := &Bmatrix{Config: cfg}
|
||||
b.RoomMap = make(map[string]string)
|
||||
b.Config = &cfg
|
||||
b.Account = account
|
||||
b.Remote = c
|
||||
b.NicknameMap = make(map[string]NicknameCacheEntry)
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *Bmatrix) Connect() error {
|
||||
var err error
|
||||
flog.Infof("Connecting %s", b.Config.Server)
|
||||
b.mc, err = matrix.NewClient(b.Config.Server, "", "")
|
||||
b.Log.Infof("Connecting %s", b.GetString("Server"))
|
||||
b.mc, err = matrix.NewClient(b.GetString("Server"), "", "")
|
||||
if err != nil {
|
||||
flog.Debugf("%#v", err)
|
||||
return err
|
||||
}
|
||||
resp, err := b.mc.Login(&matrix.ReqLogin{
|
||||
Type: "m.login.password",
|
||||
User: b.Config.Login,
|
||||
Password: b.Config.Password,
|
||||
Type: "m.login.password",
|
||||
User: b.GetString("Login"),
|
||||
Password: b.GetString("Password"),
|
||||
Identifier: matrix.NewUserIdentifier(b.GetString("Login")),
|
||||
})
|
||||
if err != nil {
|
||||
flog.Debugf("%#v", err)
|
||||
return err
|
||||
}
|
||||
b.mc.SetCredentials(resp.UserID, resp.AccessToken)
|
||||
b.UserID = resp.UserID
|
||||
flog.Info("Connection succeeded")
|
||||
b.Log.Info("Connection succeeded")
|
||||
go b.handlematrix()
|
||||
return nil
|
||||
}
|
||||
@@ -64,74 +100,470 @@ func (b *Bmatrix) Disconnect() error {
|
||||
}
|
||||
|
||||
func (b *Bmatrix) JoinChannel(channel config.ChannelInfo) error {
|
||||
resp, err := b.mc.JoinRoom(channel.Name, "", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b.Lock()
|
||||
b.RoomMap[resp.RoomID] = channel.Name
|
||||
b.Unlock()
|
||||
return err
|
||||
return b.retry(func() error {
|
||||
resp, err := b.mc.JoinRoom(channel.Name, "", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b.Lock()
|
||||
b.RoomMap[resp.RoomID] = channel.Name
|
||||
b.Unlock()
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (b *Bmatrix) Send(msg config.Message) (string, error) {
|
||||
flog.Debugf("Receiving %#v", msg)
|
||||
// ignore delete messages
|
||||
if msg.Event == config.EVENT_MSG_DELETE {
|
||||
return "", nil
|
||||
}
|
||||
b.Log.Debugf("=> Receiving %#v", msg)
|
||||
|
||||
channel := b.getRoomID(msg.Channel)
|
||||
flog.Debugf("Sending to channel %s", channel)
|
||||
if msg.Event == config.EVENT_USER_ACTION {
|
||||
b.mc.SendMessageEvent(channel, "m.room.message",
|
||||
matrix.TextMessage{"m.emote", msg.Username + msg.Text})
|
||||
return "", nil
|
||||
b.Log.Debugf("Channel %s maps to channel id %s", msg.Channel, channel)
|
||||
|
||||
username := newMatrixUsername(msg.Username)
|
||||
|
||||
// Make a action /me of the message
|
||||
if msg.Event == config.EventUserAction {
|
||||
m := matrix.TextMessage{
|
||||
MsgType: "m.emote",
|
||||
Body: username.plain + msg.Text,
|
||||
FormattedBody: username.formatted + msg.Text,
|
||||
}
|
||||
|
||||
msgID := ""
|
||||
|
||||
err := b.retry(func() error {
|
||||
resp, err := b.mc.SendMessageEvent(channel, "m.room.message", m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
msgID = resp.EventID
|
||||
|
||||
return err
|
||||
})
|
||||
|
||||
return msgID, err
|
||||
}
|
||||
b.mc.SendText(channel, msg.Username+msg.Text)
|
||||
return "", nil
|
||||
|
||||
// Delete message
|
||||
if msg.Event == config.EventMsgDelete {
|
||||
if msg.ID == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
msgID := ""
|
||||
|
||||
err := b.retry(func() error {
|
||||
resp, err := b.mc.RedactEvent(channel, msg.ID, &matrix.ReqRedact{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
msgID = resp.EventID
|
||||
|
||||
return err
|
||||
})
|
||||
|
||||
return msgID, err
|
||||
}
|
||||
|
||||
// Upload a file if it exists
|
||||
if msg.Extra != nil {
|
||||
for _, rmsg := range helper.HandleExtra(&msg, b.General) {
|
||||
rmsg := rmsg
|
||||
|
||||
err := b.retry(func() error {
|
||||
_, err := b.mc.SendText(channel, rmsg.Username+rmsg.Text)
|
||||
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
b.Log.Errorf("sendText failed: %s", err)
|
||||
}
|
||||
}
|
||||
// check if we have files to upload (from slack, telegram or mattermost)
|
||||
if len(msg.Extra["file"]) > 0 {
|
||||
return b.handleUploadFiles(&msg, channel)
|
||||
}
|
||||
}
|
||||
|
||||
// Edit message if we have an ID
|
||||
if msg.ID != "" {
|
||||
rmsg := EditedMessage{TextMessage: matrix.TextMessage{
|
||||
Body: username.plain + msg.Text,
|
||||
MsgType: "m.text",
|
||||
}}
|
||||
if b.GetBool("HTMLDisable") {
|
||||
rmsg.TextMessage.FormattedBody = username.formatted + "* " + msg.Text
|
||||
} else {
|
||||
rmsg.Format = "org.matrix.custom.html"
|
||||
rmsg.TextMessage.FormattedBody = username.formatted + "* " + helper.ParseMarkdown(msg.Text)
|
||||
}
|
||||
rmsg.NewContent = SubTextMessage{
|
||||
Body: rmsg.TextMessage.Body,
|
||||
MsgType: "m.text",
|
||||
}
|
||||
rmsg.RelatedTo = MessageRelation{
|
||||
EventID: msg.ID,
|
||||
Type: "m.replace",
|
||||
}
|
||||
|
||||
err := b.retry(func() error {
|
||||
_, err := b.mc.SendMessageEvent(channel, "m.room.message", rmsg)
|
||||
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return msg.ID, nil
|
||||
}
|
||||
|
||||
// Use notices to send join/leave events
|
||||
if msg.Event == config.EventJoinLeave {
|
||||
m := matrix.TextMessage{
|
||||
MsgType: "m.notice",
|
||||
Body: username.plain + msg.Text,
|
||||
FormattedBody: username.formatted + msg.Text,
|
||||
}
|
||||
|
||||
var (
|
||||
resp *matrix.RespSendEvent
|
||||
err error
|
||||
)
|
||||
|
||||
err = b.retry(func() error {
|
||||
resp, err = b.mc.SendMessageEvent(channel, "m.room.message", m)
|
||||
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return resp.EventID, err
|
||||
}
|
||||
|
||||
if b.GetBool("HTMLDisable") {
|
||||
var (
|
||||
resp *matrix.RespSendEvent
|
||||
err error
|
||||
)
|
||||
|
||||
err = b.retry(func() error {
|
||||
resp, err = b.mc.SendText(channel, username.plain+msg.Text)
|
||||
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return resp.EventID, err
|
||||
}
|
||||
|
||||
// Post normal message with HTML support (eg riot.im)
|
||||
var (
|
||||
resp *matrix.RespSendEvent
|
||||
err error
|
||||
)
|
||||
|
||||
err = b.retry(func() error {
|
||||
resp, err = b.mc.SendFormattedText(channel, username.plain+msg.Text,
|
||||
username.formatted+helper.ParseMarkdown(msg.Text))
|
||||
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return resp.EventID, err
|
||||
}
|
||||
|
||||
func (b *Bmatrix) getRoomID(channel string) string {
|
||||
b.RLock()
|
||||
defer b.RUnlock()
|
||||
for ID, name := range b.RoomMap {
|
||||
if name == channel {
|
||||
return ID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
func (b *Bmatrix) handlematrix() error {
|
||||
func (b *Bmatrix) handlematrix() {
|
||||
syncer := b.mc.Syncer.(*matrix.DefaultSyncer)
|
||||
syncer.OnEventType("m.room.message", func(ev *matrix.Event) {
|
||||
if (ev.Content["msgtype"].(string) == "m.text" || ev.Content["msgtype"].(string) == "m.notice" || ev.Content["msgtype"].(string) == "m.emote") && ev.Sender != b.UserID {
|
||||
b.RLock()
|
||||
channel, ok := b.RoomMap[ev.RoomID]
|
||||
b.RUnlock()
|
||||
if !ok {
|
||||
flog.Debugf("Unknown room %s", ev.RoomID)
|
||||
return
|
||||
}
|
||||
username := ev.Sender[1:]
|
||||
if b.Config.NoHomeServerSuffix {
|
||||
re := regexp.MustCompile("(.*?):.*")
|
||||
username = re.ReplaceAllString(username, `$1`)
|
||||
}
|
||||
rmsg := config.Message{Username: username, Text: ev.Content["body"].(string), Channel: channel, Account: b.Account, UserID: ev.Sender}
|
||||
if ev.Content["msgtype"].(string) == "m.emote" {
|
||||
rmsg.Event = config.EVENT_USER_ACTION
|
||||
}
|
||||
flog.Debugf("Sending message from %s on %s to gateway", ev.Sender, b.Account)
|
||||
b.Remote <- rmsg
|
||||
}
|
||||
flog.Debugf("Received: %#v", ev)
|
||||
})
|
||||
syncer.OnEventType("m.room.redaction", b.handleEvent)
|
||||
syncer.OnEventType("m.room.message", b.handleEvent)
|
||||
syncer.OnEventType("m.room.member", b.handleMemberChange)
|
||||
go func() {
|
||||
for {
|
||||
if err := b.mc.Sync(); err != nil {
|
||||
flog.Println("Sync() returned ", err)
|
||||
b.Log.Println("Sync() returned ", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (b *Bmatrix) handleEdit(ev *matrix.Event, rmsg config.Message) bool {
|
||||
relationInterface, present := ev.Content["m.relates_to"]
|
||||
newContentInterface, present2 := ev.Content["m.new_content"]
|
||||
if !(present && present2) {
|
||||
return false
|
||||
}
|
||||
|
||||
var relation MessageRelation
|
||||
if err := interface2Struct(relationInterface, &relation); err != nil {
|
||||
b.Log.Warnf("Couldn't parse 'm.relates_to' object with value %#v", relationInterface)
|
||||
return false
|
||||
}
|
||||
|
||||
var newContent SubTextMessage
|
||||
if err := interface2Struct(newContentInterface, &newContent); err != nil {
|
||||
b.Log.Warnf("Couldn't parse 'm.new_content' object with value %#v", newContentInterface)
|
||||
return false
|
||||
}
|
||||
|
||||
if relation.Type != "m.replace" {
|
||||
return false
|
||||
}
|
||||
|
||||
rmsg.ID = relation.EventID
|
||||
rmsg.Text = newContent.Body
|
||||
b.Remote <- rmsg
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (b *Bmatrix) handleMemberChange(ev *matrix.Event) {
|
||||
// Update the displayname on join messages, according to https://matrix.org/docs/spec/client_server/r0.6.1#events-on-change-of-profile-information
|
||||
if ev.Content["membership"] == "join" {
|
||||
if dn, ok := ev.Content["displayname"].(string); ok {
|
||||
b.cacheDisplayName(ev.Sender, dn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bmatrix) handleEvent(ev *matrix.Event) {
|
||||
b.Log.Debugf("== Receiving event: %#v", ev)
|
||||
if ev.Sender != b.UserID {
|
||||
b.RLock()
|
||||
channel, ok := b.RoomMap[ev.RoomID]
|
||||
b.RUnlock()
|
||||
if !ok {
|
||||
b.Log.Debugf("Unknown room %s", ev.RoomID)
|
||||
return
|
||||
}
|
||||
|
||||
// Create our message
|
||||
rmsg := config.Message{
|
||||
Username: b.getDisplayName(ev.Sender),
|
||||
Channel: channel,
|
||||
Account: b.Account,
|
||||
UserID: ev.Sender,
|
||||
ID: ev.ID,
|
||||
Avatar: b.getAvatarURL(ev.Sender),
|
||||
}
|
||||
|
||||
// Text must be a string
|
||||
if rmsg.Text, ok = ev.Content["body"].(string); !ok {
|
||||
b.Log.Errorf("Content[body] is not a string: %T\n%#v",
|
||||
ev.Content["body"], ev.Content)
|
||||
return
|
||||
}
|
||||
|
||||
// Remove homeserver suffix if configured
|
||||
if b.GetBool("NoHomeServerSuffix") {
|
||||
re := regexp.MustCompile("(.*?):.*")
|
||||
rmsg.Username = re.ReplaceAllString(rmsg.Username, `$1`)
|
||||
}
|
||||
|
||||
// Delete event
|
||||
if ev.Type == "m.room.redaction" {
|
||||
rmsg.Event = config.EventMsgDelete
|
||||
rmsg.ID = ev.Redacts
|
||||
rmsg.Text = config.EventMsgDelete
|
||||
b.Remote <- rmsg
|
||||
return
|
||||
}
|
||||
|
||||
// Do we have a /me action
|
||||
if ev.Content["msgtype"].(string) == "m.emote" {
|
||||
rmsg.Event = config.EventUserAction
|
||||
}
|
||||
|
||||
// Is it an edit?
|
||||
if b.handleEdit(ev, rmsg) {
|
||||
return
|
||||
}
|
||||
|
||||
// Do we have attachments
|
||||
if b.containsAttachment(ev.Content) {
|
||||
err := b.handleDownloadFile(&rmsg, ev.Content)
|
||||
if err != nil {
|
||||
b.Log.Errorf("download failed: %#v", err)
|
||||
}
|
||||
}
|
||||
|
||||
b.Log.Debugf("<= Sending message from %s on %s to gateway", ev.Sender, b.Account)
|
||||
b.Remote <- rmsg
|
||||
|
||||
// not crucial, so no ratelimit check here
|
||||
if err := b.mc.MarkRead(ev.RoomID, ev.ID); err != nil {
|
||||
b.Log.Errorf("couldn't mark message as read %s", err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleDownloadFile handles file download
|
||||
func (b *Bmatrix) handleDownloadFile(rmsg *config.Message, content map[string]interface{}) error {
|
||||
var (
|
||||
ok bool
|
||||
url, name, msgtype, mtype string
|
||||
info map[string]interface{}
|
||||
size float64
|
||||
)
|
||||
|
||||
rmsg.Extra = make(map[string][]interface{})
|
||||
if url, ok = content["url"].(string); !ok {
|
||||
return fmt.Errorf("url isn't a %T", url)
|
||||
}
|
||||
url = strings.Replace(url, "mxc://", b.GetString("Server")+"/_matrix/media/v1/download/", -1)
|
||||
|
||||
if info, ok = content["info"].(map[string]interface{}); !ok {
|
||||
return fmt.Errorf("info isn't a %T", info)
|
||||
}
|
||||
if size, ok = info["size"].(float64); !ok {
|
||||
return fmt.Errorf("size isn't a %T", size)
|
||||
}
|
||||
if name, ok = content["body"].(string); !ok {
|
||||
return fmt.Errorf("name isn't a %T", name)
|
||||
}
|
||||
if msgtype, ok = content["msgtype"].(string); !ok {
|
||||
return fmt.Errorf("msgtype isn't a %T", msgtype)
|
||||
}
|
||||
if mtype, ok = info["mimetype"].(string); !ok {
|
||||
return fmt.Errorf("mtype isn't a %T", mtype)
|
||||
}
|
||||
|
||||
// check if we have an image uploaded without extension
|
||||
if !strings.Contains(name, ".") {
|
||||
if msgtype == "m.image" {
|
||||
mext, _ := mime.ExtensionsByType(mtype)
|
||||
if len(mext) > 0 {
|
||||
name += mext[0]
|
||||
}
|
||||
} else {
|
||||
// just a default .png extension if we don't have mime info
|
||||
name += ".png"
|
||||
}
|
||||
}
|
||||
|
||||
// check if the size is ok
|
||||
err := helper.HandleDownloadSize(b.Log, rmsg, name, int64(size), b.General)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// actually download the file
|
||||
data, err := helper.DownloadFile(url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("download %s failed %#v", url, err)
|
||||
}
|
||||
// add the downloaded data to the message
|
||||
helper.HandleDownloadData(b.Log, rmsg, name, "", url, data, b.General)
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleUploadFiles handles native upload of files.
|
||||
func (b *Bmatrix) handleUploadFiles(msg *config.Message, channel string) (string, error) {
|
||||
for _, f := range msg.Extra["file"] {
|
||||
if fi, ok := f.(config.FileInfo); ok {
|
||||
b.handleUploadFile(msg, channel, &fi)
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// handleUploadFile handles native upload of a file.
|
||||
func (b *Bmatrix) handleUploadFile(msg *config.Message, channel string, fi *config.FileInfo) {
|
||||
username := newMatrixUsername(msg.Username)
|
||||
content := bytes.NewReader(*fi.Data)
|
||||
sp := strings.Split(fi.Name, ".")
|
||||
mtype := mime.TypeByExtension("." + sp[len(sp)-1])
|
||||
// image and video uploads send no username, we have to do this ourself here #715
|
||||
err := b.retry(func() error {
|
||||
_, err := b.mc.SendFormattedText(channel, username.plain+fi.Comment, username.formatted+fi.Comment)
|
||||
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
b.Log.Errorf("file comment failed: %#v", err)
|
||||
}
|
||||
|
||||
b.Log.Debugf("uploading file: %s %s", fi.Name, mtype)
|
||||
|
||||
var res *matrix.RespMediaUpload
|
||||
|
||||
err = b.retry(func() error {
|
||||
res, err = b.mc.UploadToContentRepo(content, mtype, int64(len(*fi.Data)))
|
||||
|
||||
return err
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
b.Log.Errorf("file upload failed: %#v", err)
|
||||
return
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.Contains(mtype, "video"):
|
||||
b.Log.Debugf("sendVideo %s", res.ContentURI)
|
||||
err = b.retry(func() error {
|
||||
_, err = b.mc.SendVideo(channel, fi.Name, res.ContentURI)
|
||||
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
b.Log.Errorf("sendVideo failed: %#v", err)
|
||||
}
|
||||
case strings.Contains(mtype, "image"):
|
||||
b.Log.Debugf("sendImage %s", res.ContentURI)
|
||||
err = b.retry(func() error {
|
||||
_, err = b.mc.SendImage(channel, fi.Name, res.ContentURI)
|
||||
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
b.Log.Errorf("sendImage failed: %#v", err)
|
||||
}
|
||||
case strings.Contains(mtype, "audio"):
|
||||
b.Log.Debugf("sendAudio %s", res.ContentURI)
|
||||
err = b.retry(func() error {
|
||||
_, err = b.mc.SendMessageEvent(channel, "m.room.message", matrix.AudioMessage{
|
||||
MsgType: "m.audio",
|
||||
Body: fi.Name,
|
||||
URL: res.ContentURI,
|
||||
Info: matrix.AudioInfo{
|
||||
Mimetype: mtype,
|
||||
Size: uint(len(*fi.Data)),
|
||||
},
|
||||
})
|
||||
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
b.Log.Errorf("sendAudio failed: %#v", err)
|
||||
}
|
||||
default:
|
||||
b.Log.Debugf("sendFile %s", res.ContentURI)
|
||||
err = b.retry(func() error {
|
||||
_, err = b.mc.SendMessageEvent(channel, "m.room.message", matrix.FileMessage{
|
||||
MsgType: "m.file",
|
||||
Body: fi.Name,
|
||||
URL: res.ContentURI,
|
||||
Info: matrix.FileInfo{
|
||||
Mimetype: mtype,
|
||||
Size: uint(len(*fi.Data)),
|
||||
},
|
||||
})
|
||||
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
b.Log.Errorf("sendFile failed: %#v", err)
|
||||
}
|
||||
}
|
||||
b.Log.Debugf("result: %#v", res)
|
||||
}
|
||||
|
||||
28
bridge/matrix/matrix_test.go
Normal file
28
bridge/matrix/matrix_test.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package bmatrix
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestPlainUsername(t *testing.T) {
|
||||
uut := newMatrixUsername("MyUser")
|
||||
|
||||
assert.Equal(t, "MyUser", uut.formatted)
|
||||
assert.Equal(t, "MyUser", uut.plain)
|
||||
}
|
||||
|
||||
func TestHTMLUsername(t *testing.T) {
|
||||
uut := newMatrixUsername("<b>MyUser</b>")
|
||||
|
||||
assert.Equal(t, "<b>MyUser</b>", uut.formatted)
|
||||
assert.Equal(t, "MyUser", uut.plain)
|
||||
}
|
||||
|
||||
func TestFancyUsername(t *testing.T) {
|
||||
uut := newMatrixUsername("<MyUser>")
|
||||
|
||||
assert.Equal(t, "<MyUser>", uut.formatted)
|
||||
assert.Equal(t, "<MyUser>", uut.plain)
|
||||
}
|
||||
199
bridge/mattermost/handlers.go
Normal file
199
bridge/mattermost/handlers.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package bmattermost
|
||||
|
||||
import (
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
"github.com/42wim/matterbridge/bridge/helper"
|
||||
"github.com/42wim/matterbridge/matterclient"
|
||||
"github.com/mattermost/mattermost-server/v5/model"
|
||||
)
|
||||
|
||||
// handleDownloadAvatar downloads the avatar of userid from channel
|
||||
// sends a EVENT_AVATAR_DOWNLOAD message to the gateway if successful.
|
||||
// logs an error message if it fails
|
||||
func (b *Bmattermost) handleDownloadAvatar(userid string, channel string) {
|
||||
rmsg := config.Message{
|
||||
Username: "system",
|
||||
Text: "avatar",
|
||||
Channel: channel,
|
||||
Account: b.Account,
|
||||
UserID: userid,
|
||||
Event: config.EventAvatarDownload,
|
||||
Extra: make(map[string][]interface{}),
|
||||
}
|
||||
if _, ok := b.avatarMap[userid]; !ok {
|
||||
data, resp := b.mc.Client.GetProfileImage(userid, "")
|
||||
if resp.Error != nil {
|
||||
b.Log.Errorf("ProfileImage download failed for %#v %s", userid, resp.Error)
|
||||
return
|
||||
}
|
||||
err := helper.HandleDownloadSize(b.Log, &rmsg, userid+".png", int64(len(data)), b.General)
|
||||
if err != nil {
|
||||
b.Log.Error(err)
|
||||
return
|
||||
}
|
||||
helper.HandleDownloadData(b.Log, &rmsg, userid+".png", rmsg.Text, "", &data, b.General)
|
||||
b.Remote <- rmsg
|
||||
}
|
||||
}
|
||||
|
||||
// handleDownloadFile handles file download
|
||||
func (b *Bmattermost) handleDownloadFile(rmsg *config.Message, id string) error {
|
||||
url, _ := b.mc.Client.GetFileLink(id)
|
||||
finfo, resp := b.mc.Client.GetFileInfo(id)
|
||||
if resp.Error != nil {
|
||||
return resp.Error
|
||||
}
|
||||
err := helper.HandleDownloadSize(b.Log, rmsg, finfo.Name, finfo.Size, b.General)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, resp := b.mc.Client.DownloadFile(id, true)
|
||||
if resp.Error != nil {
|
||||
return resp.Error
|
||||
}
|
||||
helper.HandleDownloadData(b.Log, rmsg, finfo.Name, rmsg.Text, url, &data, b.General)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bmattermost) handleMatter() {
|
||||
messages := make(chan *config.Message)
|
||||
if b.GetString("WebhookBindAddress") != "" {
|
||||
b.Log.Debugf("Choosing webhooks based receiving")
|
||||
go b.handleMatterHook(messages)
|
||||
} else {
|
||||
if b.GetString("Token") != "" {
|
||||
b.Log.Debugf("Choosing token based receiving")
|
||||
} else {
|
||||
b.Log.Debugf("Choosing login/password based receiving")
|
||||
}
|
||||
// if for some reason we only want to sent stuff to mattermost but not receive, return
|
||||
if b.GetString("WebhookBindAddress") == "" && b.GetString("WebhookURL") != "" && b.GetString("Token") == "" && b.GetString("Login") == "" {
|
||||
b.Log.Debugf("No WebhookBindAddress specified, only WebhookURL. You will not receive messages from mattermost, only sending is possible.")
|
||||
}
|
||||
go b.handleMatterClient(messages)
|
||||
}
|
||||
var ok bool
|
||||
for message := range messages {
|
||||
message.Avatar = helper.GetAvatar(b.avatarMap, message.UserID, b.General)
|
||||
message.Account = b.Account
|
||||
message.Text, ok = b.replaceAction(message.Text)
|
||||
if ok {
|
||||
message.Event = config.EventUserAction
|
||||
}
|
||||
b.Log.Debugf("<= Sending message from %s on %s to gateway", message.Username, b.Account)
|
||||
b.Log.Debugf("<= Message is %#v", message)
|
||||
b.Remote <- *message
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bmattermost) handleMatterClient(messages chan *config.Message) {
|
||||
for message := range b.mc.MessageChan {
|
||||
b.Log.Debugf("%#v", message.Raw.Data)
|
||||
|
||||
if b.skipMessage(message) {
|
||||
b.Log.Debugf("Skipped message: %#v", message)
|
||||
continue
|
||||
}
|
||||
|
||||
// only download avatars if we have a place to upload them (configured mediaserver)
|
||||
if b.General.MediaServerUpload != "" || b.General.MediaDownloadPath != "" {
|
||||
b.handleDownloadAvatar(message.UserID, message.Channel)
|
||||
}
|
||||
|
||||
b.Log.Debugf("== Receiving event %#v", message)
|
||||
|
||||
rmsg := &config.Message{
|
||||
Username: message.Username,
|
||||
UserID: message.UserID,
|
||||
Channel: message.Channel,
|
||||
Text: message.Text,
|
||||
ID: message.Post.Id,
|
||||
ParentID: message.Post.RootId, // ParentID is obsolete with mattermost
|
||||
Extra: make(map[string][]interface{}),
|
||||
}
|
||||
|
||||
// handle mattermost post properties (override username and attachments)
|
||||
b.handleProps(rmsg, message)
|
||||
|
||||
// create a text for bridges that don't support native editing
|
||||
if message.Raw.Event == model.WEBSOCKET_EVENT_POST_EDITED && !b.GetBool("EditDisable") {
|
||||
rmsg.Text = message.Text + b.GetString("EditSuffix")
|
||||
}
|
||||
|
||||
if message.Raw.Event == model.WEBSOCKET_EVENT_POST_DELETED {
|
||||
rmsg.Event = config.EventMsgDelete
|
||||
}
|
||||
|
||||
for _, id := range message.Post.FileIds {
|
||||
err := b.handleDownloadFile(rmsg, id)
|
||||
if err != nil {
|
||||
b.Log.Errorf("download failed: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Use nickname instead of username if defined
|
||||
if nick := b.mc.GetNickName(rmsg.UserID); nick != "" {
|
||||
rmsg.Username = nick
|
||||
}
|
||||
|
||||
messages <- rmsg
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bmattermost) handleMatterHook(messages chan *config.Message) {
|
||||
for {
|
||||
message := b.mh.Receive()
|
||||
b.Log.Debugf("Receiving from matterhook %#v", message)
|
||||
messages <- &config.Message{
|
||||
UserID: message.UserID,
|
||||
Username: message.UserName,
|
||||
Text: message.Text,
|
||||
Channel: message.ChannelName,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleUploadFile handles native upload of files
|
||||
func (b *Bmattermost) handleUploadFile(msg *config.Message) (string, error) {
|
||||
var err error
|
||||
var res, id string
|
||||
channelID := b.mc.GetChannelId(msg.Channel, b.TeamID)
|
||||
for _, f := range msg.Extra["file"] {
|
||||
fi := f.(config.FileInfo)
|
||||
id, err = b.mc.UploadFile(*fi.Data, channelID, fi.Name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
msg.Text = fi.Comment
|
||||
if b.GetBool("PrefixMessagesWithNick") {
|
||||
msg.Text = msg.Username + msg.Text
|
||||
}
|
||||
res, err = b.mc.PostMessageWithFiles(channelID, msg.Text, msg.ParentID, []string{id})
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (b *Bmattermost) handleProps(rmsg *config.Message, message *matterclient.Message) {
|
||||
props := message.Post.Props
|
||||
if props == nil {
|
||||
return
|
||||
}
|
||||
if _, ok := props["override_username"].(string); ok {
|
||||
rmsg.Username = props["override_username"].(string)
|
||||
}
|
||||
if _, ok := props["attachments"].([]interface{}); ok {
|
||||
rmsg.Extra["attachments"] = props["attachments"].([]interface{})
|
||||
if rmsg.Text == "" {
|
||||
for _, attachment := range rmsg.Extra["attachments"] {
|
||||
attach := attachment.(map[string]interface{})
|
||||
if attach["text"].(string) != "" {
|
||||
rmsg.Text += attach["text"].(string)
|
||||
continue
|
||||
}
|
||||
if attach["fallback"].(string) != "" {
|
||||
rmsg.Text += attach["fallback"].(string)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
225
bridge/mattermost/helpers.go
Normal file
225
bridge/mattermost/helpers.go
Normal file
@@ -0,0 +1,225 @@
|
||||
package bmattermost
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
"github.com/42wim/matterbridge/bridge/helper"
|
||||
"github.com/42wim/matterbridge/matterclient"
|
||||
"github.com/42wim/matterbridge/matterhook"
|
||||
"github.com/mattermost/mattermost-server/v5/model"
|
||||
)
|
||||
|
||||
func (b *Bmattermost) doConnectWebhookBind() error {
|
||||
switch {
|
||||
case b.GetString("WebhookURL") != "":
|
||||
b.Log.Info("Connecting using webhookurl (sending) and webhookbindaddress (receiving)")
|
||||
b.mh = matterhook.New(b.GetString("WebhookURL"),
|
||||
matterhook.Config{InsecureSkipVerify: b.GetBool("SkipTLSVerify"),
|
||||
BindAddress: b.GetString("WebhookBindAddress")})
|
||||
case b.GetString("Token") != "":
|
||||
b.Log.Info("Connecting using token (sending)")
|
||||
err := b.apiLogin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
case b.GetString("Login") != "":
|
||||
b.Log.Info("Connecting using login/password (sending)")
|
||||
err := b.apiLogin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
b.Log.Info("Connecting using webhookbindaddress (receiving)")
|
||||
b.mh = matterhook.New(b.GetString("WebhookURL"),
|
||||
matterhook.Config{InsecureSkipVerify: b.GetBool("SkipTLSVerify"),
|
||||
BindAddress: b.GetString("WebhookBindAddress")})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bmattermost) doConnectWebhookURL() error {
|
||||
b.Log.Info("Connecting using webhookurl (sending)")
|
||||
b.mh = matterhook.New(b.GetString("WebhookURL"),
|
||||
matterhook.Config{InsecureSkipVerify: b.GetBool("SkipTLSVerify"),
|
||||
DisableServer: true})
|
||||
if b.GetString("Token") != "" {
|
||||
b.Log.Info("Connecting using token (receiving)")
|
||||
err := b.apiLogin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if b.GetString("Login") != "" {
|
||||
b.Log.Info("Connecting using login/password (receiving)")
|
||||
err := b.apiLogin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bmattermost) apiLogin() error {
|
||||
password := b.GetString("Password")
|
||||
if b.GetString("Token") != "" {
|
||||
password = "token=" + b.GetString("Token")
|
||||
}
|
||||
|
||||
b.mc = matterclient.New(b.GetString("Login"), password, b.GetString("Team"), b.GetString("Server"))
|
||||
if b.GetBool("debug") {
|
||||
b.mc.SetLogLevel("debug")
|
||||
}
|
||||
b.mc.SkipTLSVerify = b.GetBool("SkipTLSVerify")
|
||||
b.mc.SkipVersionCheck = b.GetBool("SkipVersionCheck")
|
||||
b.mc.NoTLS = b.GetBool("NoTLS")
|
||||
b.Log.Infof("Connecting %s (team: %s) on %s", b.GetString("Login"), b.GetString("Team"), b.GetString("Server"))
|
||||
err := b.mc.Login()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b.Log.Info("Connection succeeded")
|
||||
b.TeamID = b.mc.GetTeamId()
|
||||
go b.mc.WsReceiver()
|
||||
go b.mc.StatusLoop()
|
||||
return nil
|
||||
}
|
||||
|
||||
// replaceAction replace the message with the correct action (/me) code
|
||||
func (b *Bmattermost) replaceAction(text string) (string, bool) {
|
||||
if strings.HasPrefix(text, "*") && strings.HasSuffix(text, "*") {
|
||||
return strings.Replace(text, "*", "", -1), true
|
||||
}
|
||||
return text, false
|
||||
}
|
||||
|
||||
func (b *Bmattermost) cacheAvatar(msg *config.Message) (string, error) {
|
||||
fi := msg.Extra["file"][0].(config.FileInfo)
|
||||
/* if we have a sha we have successfully uploaded the file to the media server,
|
||||
so we can now cache the sha */
|
||||
if fi.SHA != "" {
|
||||
b.Log.Debugf("Added %s to %s in avatarMap", fi.SHA, msg.UserID)
|
||||
b.avatarMap[msg.UserID] = fi.SHA
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// sendWebhook uses the configured WebhookURL to send the message
|
||||
func (b *Bmattermost) sendWebhook(msg config.Message) (string, error) {
|
||||
// skip events
|
||||
if msg.Event != "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if b.GetBool("PrefixMessagesWithNick") {
|
||||
msg.Text = msg.Username + msg.Text
|
||||
}
|
||||
if msg.Extra != nil {
|
||||
// this sends a message only if we received a config.EVENT_FILE_FAILURE_SIZE
|
||||
for _, rmsg := range helper.HandleExtra(&msg, b.General) {
|
||||
rmsg := rmsg // scopelint
|
||||
iconURL := config.GetIconURL(&rmsg, b.GetString("iconurl"))
|
||||
matterMessage := matterhook.OMessage{
|
||||
IconURL: iconURL,
|
||||
Channel: rmsg.Channel,
|
||||
UserName: rmsg.Username,
|
||||
Text: rmsg.Text,
|
||||
Props: make(map[string]interface{}),
|
||||
}
|
||||
matterMessage.Props["matterbridge_"+b.uuid] = true
|
||||
if err := b.mh.Send(matterMessage); err != nil {
|
||||
b.Log.Errorf("sendWebhook failed: %s ", err)
|
||||
}
|
||||
}
|
||||
|
||||
// webhook doesn't support file uploads, so we add the url manually
|
||||
if len(msg.Extra["file"]) > 0 {
|
||||
for _, f := range msg.Extra["file"] {
|
||||
fi := f.(config.FileInfo)
|
||||
if fi.URL != "" {
|
||||
msg.Text += fi.URL
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
iconURL := config.GetIconURL(&msg, b.GetString("iconurl"))
|
||||
matterMessage := matterhook.OMessage{
|
||||
IconURL: iconURL,
|
||||
Channel: msg.Channel,
|
||||
UserName: msg.Username,
|
||||
Text: msg.Text,
|
||||
Props: make(map[string]interface{}),
|
||||
}
|
||||
if msg.Avatar != "" {
|
||||
matterMessage.IconURL = msg.Avatar
|
||||
}
|
||||
matterMessage.Props["matterbridge_"+b.uuid] = true
|
||||
err := b.mh.Send(matterMessage)
|
||||
if err != nil {
|
||||
b.Log.Info(err)
|
||||
return "", err
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// skipMessages returns true if this message should not be handled
|
||||
func (b *Bmattermost) skipMessage(message *matterclient.Message) bool {
|
||||
// Handle join/leave
|
||||
if message.Type == "system_join_leave" ||
|
||||
message.Type == "system_join_channel" ||
|
||||
message.Type == "system_leave_channel" {
|
||||
if b.GetBool("nosendjoinpart") {
|
||||
return true
|
||||
}
|
||||
b.Log.Debugf("Sending JOIN_LEAVE event from %s to gateway", b.Account)
|
||||
b.Remote <- config.Message{
|
||||
Username: "system",
|
||||
Text: message.Text,
|
||||
Channel: message.Channel,
|
||||
Account: b.Account,
|
||||
Event: config.EventJoinLeave,
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Handle edited messages
|
||||
if (message.Raw.Event == model.WEBSOCKET_EVENT_POST_EDITED) && b.GetBool("EditDisable") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Ignore non-post messages
|
||||
if message.Post == nil {
|
||||
b.Log.Debugf("ignoring nil message.Post: %#v", message)
|
||||
return true
|
||||
}
|
||||
|
||||
// Ignore messages sent from matterbridge
|
||||
if message.Post.Props != nil {
|
||||
if _, ok := message.Post.Props["matterbridge_"+b.uuid].(bool); ok {
|
||||
b.Log.Debugf("sent by matterbridge, ignoring")
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Ignore messages sent from a user logged in as the bot
|
||||
if b.mc.User.Username == message.Username {
|
||||
return true
|
||||
}
|
||||
|
||||
// if the message has reactions don't repost it (for now, until we can correlate reaction with message)
|
||||
if message.Post.HasReactions {
|
||||
return true
|
||||
}
|
||||
|
||||
// ignore messages from other teams than ours
|
||||
if message.Raw.Data["team_id"].(string) != b.TeamID {
|
||||
return true
|
||||
}
|
||||
|
||||
// only handle posted, edited or deleted events
|
||||
if !(message.Raw.Event == "posted" || message.Raw.Event == model.WEBSOCKET_EVENT_POST_EDITED ||
|
||||
message.Raw.Event == model.WEBSOCKET_EVENT_POST_DELETED) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -3,54 +3,29 @@ package bmattermost
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/42wim/matterbridge/bridge"
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
"github.com/42wim/matterbridge/bridge/helper"
|
||||
"github.com/42wim/matterbridge/matterclient"
|
||||
"github.com/42wim/matterbridge/matterhook"
|
||||
log "github.com/Sirupsen/logrus"
|
||||
"strings"
|
||||
"github.com/rs/xid"
|
||||
)
|
||||
|
||||
type MMhook struct {
|
||||
mh *matterhook.Client
|
||||
}
|
||||
|
||||
type MMapi struct {
|
||||
mc *matterclient.MMClient
|
||||
mmMap map[string]string
|
||||
}
|
||||
|
||||
type MMMessage struct {
|
||||
Text string
|
||||
Channel string
|
||||
Username string
|
||||
UserID string
|
||||
ID string
|
||||
Event string
|
||||
Extra map[string][]interface{}
|
||||
}
|
||||
|
||||
type Bmattermost struct {
|
||||
MMhook
|
||||
MMapi
|
||||
Config *config.Protocol
|
||||
Remote chan config.Message
|
||||
TeamId string
|
||||
Account string
|
||||
mh *matterhook.Client
|
||||
mc *matterclient.MMClient
|
||||
uuid string
|
||||
TeamID string
|
||||
*bridge.Config
|
||||
avatarMap map[string]string
|
||||
}
|
||||
|
||||
var flog *log.Entry
|
||||
var protocol = "mattermost"
|
||||
const mattermostPlugin = "mattermost.plugin"
|
||||
|
||||
func init() {
|
||||
flog = log.WithFields(log.Fields{"module": protocol})
|
||||
}
|
||||
|
||||
func New(cfg config.Protocol, account string, c chan config.Message) *Bmattermost {
|
||||
b := &Bmattermost{}
|
||||
b.Config = &cfg
|
||||
b.Remote = c
|
||||
b.Account = account
|
||||
b.mmMap = make(map[string]string)
|
||||
func New(cfg *bridge.Config) bridge.Bridger {
|
||||
b := &Bmattermost{Config: cfg, avatarMap: make(map[string]string)}
|
||||
b.uuid = xid.New().String()
|
||||
return b
|
||||
}
|
||||
|
||||
@@ -59,71 +34,41 @@ func (b *Bmattermost) Command(cmd string) string {
|
||||
}
|
||||
|
||||
func (b *Bmattermost) Connect() error {
|
||||
if b.Config.WebhookBindAddress != "" {
|
||||
if b.Config.WebhookURL != "" {
|
||||
flog.Info("Connecting using webhookurl (sending) and webhookbindaddress (receiving)")
|
||||
b.mh = matterhook.New(b.Config.WebhookURL,
|
||||
matterhook.Config{InsecureSkipVerify: b.Config.SkipTLSVerify,
|
||||
BindAddress: b.Config.WebhookBindAddress})
|
||||
} else if b.Config.Token != "" {
|
||||
flog.Info("Connecting using token (sending)")
|
||||
err := b.apiLogin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if b.Config.Login != "" {
|
||||
flog.Info("Connecting using login/password (sending)")
|
||||
err := b.apiLogin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
flog.Info("Connecting using webhookbindaddress (receiving)")
|
||||
b.mh = matterhook.New(b.Config.WebhookURL,
|
||||
matterhook.Config{InsecureSkipVerify: b.Config.SkipTLSVerify,
|
||||
BindAddress: b.Config.WebhookBindAddress})
|
||||
if b.Account == mattermostPlugin {
|
||||
return nil
|
||||
}
|
||||
if b.GetString("WebhookBindAddress") != "" {
|
||||
if err := b.doConnectWebhookBind(); err != nil {
|
||||
return err
|
||||
}
|
||||
go b.handleMatter()
|
||||
return nil
|
||||
}
|
||||
if b.Config.WebhookURL != "" {
|
||||
flog.Info("Connecting using webhookurl (sending)")
|
||||
b.mh = matterhook.New(b.Config.WebhookURL,
|
||||
matterhook.Config{InsecureSkipVerify: b.Config.SkipTLSVerify,
|
||||
DisableServer: true})
|
||||
if b.Config.Token != "" {
|
||||
flog.Info("Connecting using token (receiving)")
|
||||
err := b.apiLogin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
go b.handleMatter()
|
||||
} else if b.Config.Login != "" {
|
||||
flog.Info("Connecting using login/password (receiving)")
|
||||
err := b.apiLogin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
go b.handleMatter()
|
||||
switch {
|
||||
case b.GetString("WebhookURL") != "":
|
||||
if err := b.doConnectWebhookURL(); err != nil {
|
||||
return err
|
||||
}
|
||||
go b.handleMatter()
|
||||
return nil
|
||||
} else if b.Config.Token != "" {
|
||||
flog.Info("Connecting using token (sending and receiving)")
|
||||
case b.GetString("Token") != "":
|
||||
b.Log.Info("Connecting using token (sending and receiving)")
|
||||
err := b.apiLogin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
go b.handleMatter()
|
||||
} else if b.Config.Login != "" {
|
||||
flog.Info("Connecting using login/password (sending and receiving)")
|
||||
case b.GetString("Login") != "":
|
||||
b.Log.Info("Connecting using login/password (sending and receiving)")
|
||||
err := b.apiLogin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
go b.handleMatter()
|
||||
}
|
||||
if b.Config.WebhookBindAddress == "" && b.Config.WebhookURL == "" && b.Config.Login == "" && b.Config.Token == "" {
|
||||
return errors.New("No connection method found. See that you have WebhookBindAddress, WebhookURL or Token/Login/Password/Server/Team configured.")
|
||||
if b.GetString("WebhookBindAddress") == "" && b.GetString("WebhookURL") == "" &&
|
||||
b.GetString("Login") == "" && b.GetString("Token") == "" {
|
||||
return errors.New("no connection method found. See that you have WebhookBindAddress, WebhookURL or Token/Login/Password/Server/Team configured")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -133,9 +78,12 @@ func (b *Bmattermost) Disconnect() error {
|
||||
}
|
||||
|
||||
func (b *Bmattermost) JoinChannel(channel config.ChannelInfo) error {
|
||||
if b.Account == mattermostPlugin {
|
||||
return nil
|
||||
}
|
||||
// we can only join channels using the API
|
||||
if b.Config.WebhookURL == "" && b.Config.WebhookBindAddress == "" {
|
||||
id := b.mc.GetChannelId(channel.Name, "")
|
||||
if b.GetString("WebhookURL") == "" && b.GetString("WebhookBindAddress") == "" {
|
||||
id := b.mc.GetChannelId(channel.Name, b.TeamID)
|
||||
if id == "" {
|
||||
return fmt.Errorf("Could not find channel ID for channel %s", channel.Name)
|
||||
}
|
||||
@@ -145,189 +93,71 @@ func (b *Bmattermost) JoinChannel(channel config.ChannelInfo) error {
|
||||
}
|
||||
|
||||
func (b *Bmattermost) Send(msg config.Message) (string, error) {
|
||||
flog.Debugf("Receiving %#v", msg)
|
||||
if msg.Event == config.EVENT_USER_ACTION {
|
||||
msg.Text = "*" + msg.Text + "*"
|
||||
}
|
||||
nick := msg.Username
|
||||
message := msg.Text
|
||||
channel := msg.Channel
|
||||
|
||||
if b.Config.PrefixMessagesWithNick {
|
||||
message = nick + message
|
||||
}
|
||||
if b.Config.WebhookURL != "" {
|
||||
matterMessage := matterhook.OMessage{IconURL: b.Config.IconURL}
|
||||
matterMessage.IconURL = msg.Avatar
|
||||
matterMessage.Channel = channel
|
||||
matterMessage.UserName = nick
|
||||
matterMessage.Type = ""
|
||||
matterMessage.Text = message
|
||||
matterMessage.Text = message
|
||||
matterMessage.Props = make(map[string]interface{})
|
||||
matterMessage.Props["matterbridge"] = true
|
||||
err := b.mh.Send(matterMessage)
|
||||
if err != nil {
|
||||
flog.Info(err)
|
||||
return "", err
|
||||
}
|
||||
if b.Account == mattermostPlugin {
|
||||
return "", nil
|
||||
}
|
||||
if msg.Event == config.EVENT_MSG_DELETE {
|
||||
b.Log.Debugf("=> Receiving %#v", msg)
|
||||
|
||||
// Make a action /me of the message
|
||||
if msg.Event == config.EventUserAction {
|
||||
msg.Text = "*" + msg.Text + "*"
|
||||
}
|
||||
|
||||
// map the file SHA to our user (caches the avatar)
|
||||
if msg.Event == config.EventAvatarDownload {
|
||||
return b.cacheAvatar(&msg)
|
||||
}
|
||||
|
||||
// Use webhook to send the message
|
||||
if b.GetString("WebhookURL") != "" {
|
||||
return b.sendWebhook(msg)
|
||||
}
|
||||
|
||||
// Delete message
|
||||
if msg.Event == config.EventMsgDelete {
|
||||
if msg.ID == "" {
|
||||
return "", nil
|
||||
}
|
||||
return msg.ID, b.mc.DeleteMessage(msg.ID)
|
||||
}
|
||||
|
||||
// Handle prefix hint for unthreaded messages.
|
||||
if msg.ParentNotFound() {
|
||||
msg.ParentID = ""
|
||||
msg.Text = fmt.Sprintf("[thread]: %s", msg.Text)
|
||||
}
|
||||
|
||||
// we only can reply to the root of the thread, not to a specific ID (like discord for example does)
|
||||
if msg.ParentID != "" {
|
||||
post, res := b.mc.Client.GetPost(msg.ParentID, "")
|
||||
if res.Error != nil {
|
||||
b.Log.Errorf("getting post %s failed: %s", msg.ParentID, res.Error.DetailedError)
|
||||
}
|
||||
msg.ParentID = post.RootId
|
||||
}
|
||||
|
||||
// Upload a file if it exists
|
||||
if msg.Extra != nil {
|
||||
for _, rmsg := range helper.HandleExtra(&msg, b.General) {
|
||||
if _, err := b.mc.PostMessage(b.mc.GetChannelId(rmsg.Channel, b.TeamID), rmsg.Username+rmsg.Text, msg.ParentID); err != nil {
|
||||
b.Log.Errorf("PostMessage failed: %s", err)
|
||||
}
|
||||
}
|
||||
if len(msg.Extra["file"]) > 0 {
|
||||
var err error
|
||||
var res, id string
|
||||
for _, f := range msg.Extra["file"] {
|
||||
fi := f.(config.FileInfo)
|
||||
id, err = b.mc.UploadFile(*fi.Data, b.mc.GetChannelId(channel, ""), fi.Name)
|
||||
if err != nil {
|
||||
flog.Debugf("ERROR %#v", err)
|
||||
return "", err
|
||||
}
|
||||
message = "uploaded a file: " + fi.Name
|
||||
if b.Config.PrefixMessagesWithNick {
|
||||
message = nick + "uploaded a file: " + fi.Name
|
||||
}
|
||||
res, err = b.mc.PostMessageWithFiles(b.mc.GetChannelId(channel, ""), message, []string{id})
|
||||
}
|
||||
return res, err
|
||||
return b.handleUploadFile(&msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Prepend nick if configured
|
||||
if b.GetBool("PrefixMessagesWithNick") {
|
||||
msg.Text = msg.Username + msg.Text
|
||||
}
|
||||
|
||||
// Edit message if we have an ID
|
||||
if msg.ID != "" {
|
||||
return b.mc.EditMessage(msg.ID, message)
|
||||
return b.mc.EditMessage(msg.ID, msg.Text)
|
||||
}
|
||||
return b.mc.PostMessage(b.mc.GetChannelId(channel, ""), message)
|
||||
}
|
||||
|
||||
func (b *Bmattermost) handleMatter() {
|
||||
mchan := make(chan *MMMessage)
|
||||
if b.Config.WebhookBindAddress != "" {
|
||||
flog.Debugf("Choosing webhooks based receiving")
|
||||
go b.handleMatterHook(mchan)
|
||||
} else {
|
||||
if b.Config.Token != "" {
|
||||
flog.Debugf("Choosing token based receiving")
|
||||
} else {
|
||||
flog.Debugf("Choosing login/password based receiving")
|
||||
}
|
||||
go b.handleMatterClient(mchan)
|
||||
}
|
||||
for message := range mchan {
|
||||
rmsg := config.Message{Username: message.Username, Channel: message.Channel, Account: b.Account, UserID: message.UserID, ID: message.ID, Event: message.Event, Extra: message.Extra}
|
||||
text, ok := b.replaceAction(message.Text)
|
||||
if ok {
|
||||
rmsg.Event = config.EVENT_USER_ACTION
|
||||
}
|
||||
rmsg.Text = text
|
||||
flog.Debugf("Sending message from %s on %s to gateway", message.Username, b.Account)
|
||||
flog.Debugf("Message is %#v", rmsg)
|
||||
b.Remote <- rmsg
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bmattermost) handleMatterClient(mchan chan *MMMessage) {
|
||||
for message := range b.mc.MessageChan {
|
||||
flog.Debugf("%#v", message.Raw.Data)
|
||||
if message.Type == "system_join_leave" ||
|
||||
message.Type == "system_join_channel" ||
|
||||
message.Type == "system_leave_channel" {
|
||||
flog.Debugf("Sending JOIN_LEAVE event from %s to gateway", b.Account)
|
||||
b.Remote <- config.Message{Username: "system", Text: message.Text, Channel: message.Channel, Account: b.Account, Event: config.EVENT_JOIN_LEAVE}
|
||||
continue
|
||||
}
|
||||
if (message.Raw.Event == "post_edited") && b.Config.EditDisable {
|
||||
continue
|
||||
}
|
||||
|
||||
m := &MMMessage{Extra: make(map[string][]interface{})}
|
||||
|
||||
props := message.Post.Props
|
||||
if props != nil {
|
||||
if _, ok := props["matterbridge"].(bool); ok {
|
||||
flog.Debugf("sent by matterbridge, ignoring")
|
||||
continue
|
||||
}
|
||||
if _, ok := props["override_username"].(string); ok {
|
||||
message.Username = props["override_username"].(string)
|
||||
}
|
||||
if _, ok := props["attachments"].([]interface{}); ok {
|
||||
m.Extra["attachments"] = props["attachments"].([]interface{})
|
||||
}
|
||||
}
|
||||
// do not post our own messages back to irc
|
||||
// only listen to message from our team
|
||||
if (message.Raw.Event == "posted" || message.Raw.Event == "post_edited" || message.Raw.Event == "post_deleted") &&
|
||||
b.mc.User.Username != message.Username && message.Raw.Data["team_id"].(string) == b.TeamId {
|
||||
// if the message has reactions don't repost it (for now, until we can correlate reaction with message)
|
||||
if message.Post.HasReactions {
|
||||
continue
|
||||
}
|
||||
flog.Debugf("Receiving from matterclient %#v", message)
|
||||
m.UserID = message.UserID
|
||||
m.Username = message.Username
|
||||
m.Channel = message.Channel
|
||||
m.Text = message.Text
|
||||
m.ID = message.Post.Id
|
||||
if message.Raw.Event == "post_edited" && !b.Config.EditDisable {
|
||||
m.Text = message.Text + b.Config.EditSuffix
|
||||
}
|
||||
if message.Raw.Event == "post_deleted" {
|
||||
m.Event = config.EVENT_MSG_DELETE
|
||||
}
|
||||
if len(message.Post.FileIds) > 0 {
|
||||
for _, link := range b.mc.GetFileLinks(message.Post.FileIds) {
|
||||
m.Text = m.Text + "\n" + link
|
||||
}
|
||||
}
|
||||
mchan <- m
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bmattermost) handleMatterHook(mchan chan *MMMessage) {
|
||||
for {
|
||||
message := b.mh.Receive()
|
||||
flog.Debugf("Receiving from matterhook %#v", message)
|
||||
m := &MMMessage{}
|
||||
m.UserID = message.UserID
|
||||
m.Username = message.UserName
|
||||
m.Text = message.Text
|
||||
m.Channel = message.ChannelName
|
||||
mchan <- m
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bmattermost) apiLogin() error {
|
||||
password := b.Config.Password
|
||||
if b.Config.Token != "" {
|
||||
password = "MMAUTHTOKEN=" + b.Config.Token
|
||||
}
|
||||
|
||||
b.mc = matterclient.New(b.Config.Login, password,
|
||||
b.Config.Team, b.Config.Server)
|
||||
b.mc.SkipTLSVerify = b.Config.SkipTLSVerify
|
||||
b.mc.NoTLS = b.Config.NoTLS
|
||||
flog.Infof("Connecting %s (team: %s) on %s", b.Config.Login, b.Config.Team, b.Config.Server)
|
||||
err := b.mc.Login()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
flog.Info("Connection succeeded")
|
||||
b.TeamId = b.mc.GetTeamId()
|
||||
go b.mc.WsReceiver()
|
||||
go b.mc.StatusLoop()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bmattermost) replaceAction(text string) (string, bool) {
|
||||
if strings.HasPrefix(text, "*") && strings.HasSuffix(text, "*") {
|
||||
return strings.Replace(text, "*", "", -1), true
|
||||
}
|
||||
return text, false
|
||||
|
||||
// Post normal message
|
||||
return b.mc.PostMessage(b.mc.GetChannelId(msg.Channel, b.TeamID), msg.Text, msg.ParentID)
|
||||
}
|
||||
|
||||
101
bridge/msteams/handler.go
Normal file
101
bridge/msteams/handler.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package bmsteams
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"strings"
|
||||
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
"github.com/42wim/matterbridge/bridge/helper"
|
||||
|
||||
msgraph "github.com/yaegashi/msgraph.go/beta"
|
||||
)
|
||||
|
||||
func (b *Bmsteams) findFile(weburl string) (string, error) {
|
||||
itemRB, err := b.gc.GetDriveItemByURL(b.ctx, weburl)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
itemRB.Workbook().Worksheets()
|
||||
b.gc.Workbooks()
|
||||
item, err := itemRB.Request().Get(b.ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if url, ok := item.GetAdditionalData("@microsoft.graph.downloadUrl"); ok {
|
||||
return url.(string), nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// handleDownloadFile handles file download
|
||||
func (b *Bmsteams) handleDownloadFile(rmsg *config.Message, filename, weburl string) error {
|
||||
realURL, err := b.findFile(weburl)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Actually download the file.
|
||||
data, err := helper.DownloadFile(realURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("download %s failed %#v", weburl, err)
|
||||
}
|
||||
|
||||
// If a comment is attached to the file(s) it is in the 'Text' field of the teams messge event
|
||||
// and should be added as comment to only one of the files. We reset the 'Text' field to ensure
|
||||
// that the comment is not duplicated.
|
||||
comment := rmsg.Text
|
||||
rmsg.Text = ""
|
||||
helper.HandleDownloadData(b.Log, rmsg, filename, comment, weburl, data, b.General)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bmsteams) handleAttachments(rmsg *config.Message, msg msgraph.ChatMessage) {
|
||||
for _, a := range msg.Attachments {
|
||||
//remove the attachment tags from the text
|
||||
rmsg.Text = attachRE.ReplaceAllString(rmsg.Text, "")
|
||||
|
||||
//handle a code snippet (code block)
|
||||
if *a.ContentType == "application/vnd.microsoft.card.codesnippet" {
|
||||
b.handleCodeSnippet(rmsg, a)
|
||||
continue
|
||||
}
|
||||
|
||||
//handle the download
|
||||
err := b.handleDownloadFile(rmsg, *a.Name, *a.ContentURL)
|
||||
if err != nil {
|
||||
b.Log.Errorf("download of %s failed: %s", *a.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type AttachContent struct {
|
||||
Language string `json:"language"`
|
||||
CodeSnippetURL string `json:"codeSnippetUrl"`
|
||||
}
|
||||
|
||||
func (b *Bmsteams) handleCodeSnippet(rmsg *config.Message, attach msgraph.ChatMessageAttachment) {
|
||||
var content AttachContent
|
||||
err := json.Unmarshal([]byte(*attach.Content), &content)
|
||||
if err != nil {
|
||||
b.Log.Errorf("unmarshal codesnippet failed: %s", err)
|
||||
return
|
||||
}
|
||||
s := strings.Split(content.CodeSnippetURL, "/")
|
||||
if len(s) != 13 {
|
||||
b.Log.Errorf("codesnippetUrl has unexpected size: %s", content.CodeSnippetURL)
|
||||
return
|
||||
}
|
||||
resp, err := b.gc.Teams().Request().Client().Get(content.CodeSnippetURL)
|
||||
if err != nil {
|
||||
b.Log.Errorf("retrieving snippet content failed:%s", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
res, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
b.Log.Errorf("reading snippet data failed: %s", err)
|
||||
return
|
||||
}
|
||||
rmsg.Text = rmsg.Text + "\n```" + content.Language + "\n" + string(res) + "\n```\n"
|
||||
}
|
||||
227
bridge/msteams/msteams.go
Normal file
227
bridge/msteams/msteams.go
Normal file
@@ -0,0 +1,227 @@
|
||||
package bmsteams
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/42wim/matterbridge/bridge"
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
"github.com/davecgh/go-spew/spew"
|
||||
|
||||
"github.com/mattn/godown"
|
||||
msgraph "github.com/yaegashi/msgraph.go/beta"
|
||||
"github.com/yaegashi/msgraph.go/msauth"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
var defaultScopes = []string{"openid", "profile", "offline_access", "Group.Read.All", "Group.ReadWrite.All"}
|
||||
var attachRE = regexp.MustCompile(`<attachment id=.*?attachment>`)
|
||||
|
||||
type Bmsteams struct {
|
||||
gc *msgraph.GraphServiceRequestBuilder
|
||||
ctx context.Context
|
||||
botID string
|
||||
*bridge.Config
|
||||
}
|
||||
|
||||
func New(cfg *bridge.Config) bridge.Bridger {
|
||||
return &Bmsteams{Config: cfg}
|
||||
}
|
||||
|
||||
func (b *Bmsteams) Connect() error {
|
||||
tokenCachePath := b.GetString("sessionFile")
|
||||
if tokenCachePath == "" {
|
||||
tokenCachePath = "msteams_session.json"
|
||||
}
|
||||
ctx := context.Background()
|
||||
m := msauth.NewManager()
|
||||
m.LoadFile(tokenCachePath) //nolint:errcheck
|
||||
ts, err := m.DeviceAuthorizationGrant(ctx, b.GetString("TenantID"), b.GetString("ClientID"), defaultScopes, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = m.SaveFile(tokenCachePath)
|
||||
if err != nil {
|
||||
b.Log.Errorf("Couldn't save sessionfile in %s: %s", tokenCachePath, err)
|
||||
}
|
||||
// make file readable only for matterbridge user
|
||||
err = os.Chmod(tokenCachePath, 0600)
|
||||
if err != nil {
|
||||
b.Log.Errorf("Couldn't change permissions for %s: %s", tokenCachePath, err)
|
||||
}
|
||||
httpClient := oauth2.NewClient(ctx, ts)
|
||||
graphClient := msgraph.NewClient(httpClient)
|
||||
b.gc = graphClient
|
||||
b.ctx = ctx
|
||||
|
||||
err = b.setBotID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b.Log.Info("Connection succeeded")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bmsteams) Disconnect() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bmsteams) JoinChannel(channel config.ChannelInfo) error {
|
||||
go func(name string) {
|
||||
for {
|
||||
err := b.poll(name)
|
||||
if err != nil {
|
||||
b.Log.Errorf("polling failed for %s: %s. retrying in 5 seconds", name, err)
|
||||
}
|
||||
time.Sleep(time.Second * 5)
|
||||
}
|
||||
}(channel.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bmsteams) Send(msg config.Message) (string, error) {
|
||||
b.Log.Debugf("=> Receiving %#v", msg)
|
||||
if msg.ParentValid() {
|
||||
return b.sendReply(msg)
|
||||
}
|
||||
|
||||
// Handle prefix hint for unthreaded messages.
|
||||
if msg.ParentNotFound() {
|
||||
msg.ParentID = ""
|
||||
msg.Text = fmt.Sprintf("[thread]: %s", msg.Text)
|
||||
}
|
||||
|
||||
ct := b.gc.Teams().ID(b.GetString("TeamID")).Channels().ID(msg.Channel).Messages().Request()
|
||||
text := msg.Username + msg.Text
|
||||
content := &msgraph.ItemBody{Content: &text}
|
||||
rmsg := &msgraph.ChatMessage{Body: content}
|
||||
res, err := ct.Add(b.ctx, rmsg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return *res.ID, nil
|
||||
}
|
||||
|
||||
func (b *Bmsteams) sendReply(msg config.Message) (string, error) {
|
||||
ct := b.gc.Teams().ID(b.GetString("TeamID")).Channels().ID(msg.Channel).Messages().ID(msg.ParentID).Replies().Request()
|
||||
// Handle prefix hint for unthreaded messages.
|
||||
|
||||
text := msg.Username + msg.Text
|
||||
content := &msgraph.ItemBody{Content: &text}
|
||||
rmsg := &msgraph.ChatMessage{Body: content}
|
||||
res, err := ct.Add(b.ctx, rmsg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return *res.ID, nil
|
||||
}
|
||||
|
||||
func (b *Bmsteams) getMessages(channel string) ([]msgraph.ChatMessage, error) {
|
||||
ct := b.gc.Teams().ID(b.GetString("TeamID")).Channels().ID(channel).Messages().Request()
|
||||
rct, err := ct.Get(b.ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b.Log.Debugf("got %#v messages", len(rct))
|
||||
return rct, nil
|
||||
}
|
||||
|
||||
//nolint:gocognit
|
||||
func (b *Bmsteams) poll(channelName string) error {
|
||||
msgmap := make(map[string]time.Time)
|
||||
b.Log.Debug("getting initial messages")
|
||||
res, err := b.getMessages(channelName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, msg := range res {
|
||||
msgmap[*msg.ID] = *msg.CreatedDateTime
|
||||
if msg.LastModifiedDateTime != nil {
|
||||
msgmap[*msg.ID] = *msg.LastModifiedDateTime
|
||||
}
|
||||
}
|
||||
time.Sleep(time.Second * 5)
|
||||
b.Log.Debug("polling for messages")
|
||||
for {
|
||||
res, err := b.getMessages(channelName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := len(res) - 1; i >= 0; i-- {
|
||||
msg := res[i]
|
||||
if mtime, ok := msgmap[*msg.ID]; ok {
|
||||
if mtime == *msg.CreatedDateTime && msg.LastModifiedDateTime == nil {
|
||||
continue
|
||||
}
|
||||
if msg.LastModifiedDateTime != nil && mtime == *msg.LastModifiedDateTime {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if b.GetBool("debug") {
|
||||
b.Log.Debug("Msg dump: ", spew.Sdump(msg))
|
||||
}
|
||||
|
||||
// skip non-user message for now.
|
||||
if msg.From.User == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if *msg.From.User.ID == b.botID {
|
||||
b.Log.Debug("skipping own message")
|
||||
msgmap[*msg.ID] = *msg.CreatedDateTime
|
||||
continue
|
||||
}
|
||||
|
||||
msgmap[*msg.ID] = *msg.CreatedDateTime
|
||||
if msg.LastModifiedDateTime != nil {
|
||||
msgmap[*msg.ID] = *msg.LastModifiedDateTime
|
||||
}
|
||||
b.Log.Debugf("<= Sending message from %s on %s to gateway", *msg.From.User.DisplayName, b.Account)
|
||||
text := b.convertToMD(*msg.Body.Content)
|
||||
rmsg := config.Message{
|
||||
Username: *msg.From.User.DisplayName,
|
||||
Text: text,
|
||||
Channel: channelName,
|
||||
Account: b.Account,
|
||||
Avatar: "",
|
||||
UserID: *msg.From.User.ID,
|
||||
ID: *msg.ID,
|
||||
Extra: make(map[string][]interface{}),
|
||||
}
|
||||
|
||||
b.handleAttachments(&rmsg, msg)
|
||||
b.Log.Debugf("<= Message is %#v", rmsg)
|
||||
b.Remote <- rmsg
|
||||
}
|
||||
time.Sleep(time.Second * 5)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bmsteams) setBotID() error {
|
||||
req := b.gc.Me().Request()
|
||||
r, err := req.Get(b.ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b.botID = *r.ID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bmsteams) convertToMD(text string) string {
|
||||
if !strings.Contains(text, "<div>") {
|
||||
return text
|
||||
}
|
||||
var sb strings.Builder
|
||||
err := godown.Convert(&sb, strings.NewReader(text), nil)
|
||||
if err != nil {
|
||||
b.Log.Errorf("Couldn't convert message to markdown %s", text)
|
||||
return text
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
96
bridge/mumble/handlers.go
Normal file
96
bridge/mumble/handlers.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package bmumble
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"layeh.com/gumble/gumble"
|
||||
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
"github.com/42wim/matterbridge/bridge/helper"
|
||||
)
|
||||
|
||||
func (b *Bmumble) handleServerConfig(event *gumble.ServerConfigEvent) {
|
||||
b.serverConfigUpdate <- *event
|
||||
}
|
||||
|
||||
func (b *Bmumble) handleTextMessage(event *gumble.TextMessageEvent) {
|
||||
sender := "unknown"
|
||||
if event.TextMessage.Sender != nil {
|
||||
sender = event.TextMessage.Sender.Name
|
||||
}
|
||||
// If the text message is received before receiving a ServerSync
|
||||
// and UserState, Client.Self or Self.Channel are nil
|
||||
if event.Client.Self == nil || event.Client.Self.Channel == nil {
|
||||
b.Log.Warn("Connection bootstrap not finished, discarding text message")
|
||||
return
|
||||
}
|
||||
// Convert Mumble HTML messages to markdown
|
||||
parts, err := b.convertHTMLtoMarkdown(event.TextMessage.Message)
|
||||
if err != nil {
|
||||
b.Log.Error(err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
for i, part := range parts {
|
||||
// Construct matterbridge message and pass on to the gateway
|
||||
rmsg := config.Message{
|
||||
Channel: strconv.FormatUint(uint64(event.Client.Self.Channel.ID), 10),
|
||||
Username: sender,
|
||||
UserID: sender + "@" + b.Host,
|
||||
Account: b.Account,
|
||||
}
|
||||
if part.Image == nil {
|
||||
rmsg.Text = part.Text
|
||||
} else {
|
||||
fname := b.Account + "_" + strconv.FormatInt(now.UnixNano(), 10) + "_" + strconv.Itoa(i) + part.FileExtension
|
||||
rmsg.Extra = make(map[string][]interface{})
|
||||
if err = helper.HandleDownloadSize(b.Log, &rmsg, fname, int64(len(part.Image)), b.General); err != nil {
|
||||
b.Log.WithError(err).Warn("not including image in message")
|
||||
continue
|
||||
}
|
||||
helper.HandleDownloadData(b.Log, &rmsg, fname, "", "", &part.Image, b.General)
|
||||
}
|
||||
b.Log.Debugf("Sending message to gateway: %+v", rmsg)
|
||||
b.Remote <- rmsg
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bmumble) handleConnect(event *gumble.ConnectEvent) {
|
||||
// Set the user's "bio"/comment
|
||||
if comment := b.GetString("UserComment"); comment != "" && event.Client.Self != nil {
|
||||
event.Client.Self.SetComment(comment)
|
||||
}
|
||||
// No need to talk or listen
|
||||
event.Client.Self.SetSelfDeafened(true)
|
||||
event.Client.Self.SetSelfMuted(true)
|
||||
// if the Channel variable is set, this is a reconnect -> rejoin channel
|
||||
if b.Channel != nil {
|
||||
if err := b.doJoin(event.Client, *b.Channel); err != nil {
|
||||
b.Log.Error(err)
|
||||
}
|
||||
b.Remote <- config.Message{
|
||||
Username: "system",
|
||||
Text: "rejoin",
|
||||
Channel: "",
|
||||
Account: b.Account,
|
||||
Event: config.EventRejoinChannels,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bmumble) handleUserChange(event *gumble.UserChangeEvent) {
|
||||
// Only care about changes to self
|
||||
if event.User != event.Client.Self {
|
||||
return
|
||||
}
|
||||
// Someone attempted to move the user out of the configured channel; attempt to join back
|
||||
if b.Channel != nil {
|
||||
if err := b.doJoin(event.Client, *b.Channel); err != nil {
|
||||
b.Log.Error(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bmumble) handleDisconnect(event *gumble.DisconnectEvent) {
|
||||
b.connected <- *event
|
||||
}
|
||||
143
bridge/mumble/helpers.go
Normal file
143
bridge/mumble/helpers.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package bmumble
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"mime"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
"github.com/mattn/godown"
|
||||
"github.com/vincent-petithory/dataurl"
|
||||
)
|
||||
|
||||
type MessagePart struct {
|
||||
Text string
|
||||
FileExtension string
|
||||
Image []byte
|
||||
}
|
||||
|
||||
func (b *Bmumble) decodeImage(uri string, parts *[]MessagePart) error {
|
||||
// Decode the data:image/... URI
|
||||
image, err := dataurl.DecodeString(uri)
|
||||
if err != nil {
|
||||
b.Log.WithError(err).Info("No image extracted")
|
||||
return err
|
||||
}
|
||||
// Determine the file extensions for that image
|
||||
ext, err := mime.ExtensionsByType(image.MediaType.ContentType())
|
||||
if err != nil || len(ext) == 0 {
|
||||
b.Log.WithError(err).Infof("No file extension registered for MIME type '%s'", image.MediaType.ContentType())
|
||||
return err
|
||||
}
|
||||
// Add the image to the MessagePart slice
|
||||
*parts = append(*parts, MessagePart{"", ext[0], image.Data})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bmumble) tokenize(t *string) ([]MessagePart, error) {
|
||||
// `^(.*?)` matches everything before the image
|
||||
// `!\[[^\]]*\]\(` matches the `]+)` matches the data: URI used by Mumble
|
||||
// `\)` matches the closing parenthesis after the URI
|
||||
// `(.*)$` matches the remaining text to be examined in the next iteration
|
||||
p := regexp.MustCompile(`^(?ms)(.*?)!\[[^\]]*\]\((data:image\/[^)]+)\)(.*)$`)
|
||||
remaining := *t
|
||||
var parts []MessagePart
|
||||
for {
|
||||
tokens := p.FindStringSubmatch(remaining)
|
||||
if tokens == nil {
|
||||
// no match -> remaining string is non-image text
|
||||
pre := strings.TrimSpace(remaining)
|
||||
if len(pre) > 0 {
|
||||
parts = append(parts, MessagePart{pre, "", nil})
|
||||
}
|
||||
return parts, nil
|
||||
}
|
||||
|
||||
// tokens[1] is the text before the image
|
||||
if len(tokens[1]) > 0 {
|
||||
pre := strings.TrimSpace(tokens[1])
|
||||
parts = append(parts, MessagePart{pre, "", nil})
|
||||
}
|
||||
// tokens[2] is the image URL
|
||||
uri, err := dataurl.UnescapeToString(strings.TrimSpace(strings.ReplaceAll(tokens[2], " ", "")))
|
||||
if err != nil {
|
||||
b.Log.WithError(err).Info("URL unescaping failed")
|
||||
remaining = strings.TrimSpace(tokens[3])
|
||||
continue
|
||||
}
|
||||
err = b.decodeImage(uri, &parts)
|
||||
if err != nil {
|
||||
b.Log.WithError(err).Info("Decoding the image failed")
|
||||
}
|
||||
// tokens[3] is the text after the image, processed in the next iteration
|
||||
remaining = strings.TrimSpace(tokens[3])
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bmumble) convertHTMLtoMarkdown(html string) ([]MessagePart, error) {
|
||||
var sb strings.Builder
|
||||
err := godown.Convert(&sb, strings.NewReader(html), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
markdown := sb.String()
|
||||
b.Log.Debugf("### to markdown: %s", markdown)
|
||||
return b.tokenize(&markdown)
|
||||
}
|
||||
|
||||
func (b *Bmumble) extractFiles(msg *config.Message) []config.Message {
|
||||
var messages []config.Message
|
||||
if msg.Extra == nil || len(msg.Extra["file"]) == 0 {
|
||||
return messages
|
||||
}
|
||||
// Create a separate message for each file
|
||||
for _, f := range msg.Extra["file"] {
|
||||
fi := f.(config.FileInfo)
|
||||
imsg := config.Message{
|
||||
Channel: msg.Channel,
|
||||
Username: msg.Username,
|
||||
UserID: msg.UserID,
|
||||
Account: msg.Account,
|
||||
Protocol: msg.Protocol,
|
||||
Timestamp: msg.Timestamp,
|
||||
Event: "mumble_image",
|
||||
}
|
||||
// If no data is present for the file, send a link instead
|
||||
if fi.Data == nil || len(*fi.Data) == 0 {
|
||||
if len(fi.URL) > 0 {
|
||||
imsg.Text = fmt.Sprintf(`<a href="%s">%s</a>`, fi.URL, fi.URL)
|
||||
messages = append(messages, imsg)
|
||||
} else {
|
||||
b.Log.Infof("Not forwarding file without local data")
|
||||
}
|
||||
continue
|
||||
}
|
||||
mimeType := http.DetectContentType(*fi.Data)
|
||||
// Mumble only supports images natively, send a link instead
|
||||
if !strings.HasPrefix(mimeType, "image/") {
|
||||
if len(fi.URL) > 0 {
|
||||
imsg.Text = fmt.Sprintf(`<a href="%s">%s</a>`, fi.URL, fi.URL)
|
||||
messages = append(messages, imsg)
|
||||
} else {
|
||||
b.Log.Infof("Not forwarding file of type %s", mimeType)
|
||||
}
|
||||
continue
|
||||
}
|
||||
mimeType = strings.TrimSpace(strings.Split(mimeType, ";")[0])
|
||||
// Build data:image/...;base64,... style image URL and embed image directly into the message
|
||||
du := dataurl.New(*fi.Data, mimeType)
|
||||
dataURL, err := du.MarshalText()
|
||||
if err != nil {
|
||||
b.Log.WithError(err).Infof("Image Serialization into data URL failed (type: %s, length: %d)", mimeType, len(*fi.Data))
|
||||
continue
|
||||
}
|
||||
imsg.Text = fmt.Sprintf(`<img src="%s"/>`, dataURL)
|
||||
messages = append(messages, imsg)
|
||||
}
|
||||
// Remove files from original message
|
||||
msg.Extra["file"] = nil
|
||||
return messages
|
||||
}
|
||||
259
bridge/mumble/mumble.go
Normal file
259
bridge/mumble/mumble.go
Normal file
@@ -0,0 +1,259 @@
|
||||
package bmumble
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"layeh.com/gumble/gumble"
|
||||
"layeh.com/gumble/gumbleutil"
|
||||
|
||||
"github.com/42wim/matterbridge/bridge"
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
"github.com/42wim/matterbridge/bridge/helper"
|
||||
stripmd "github.com/writeas/go-strip-markdown"
|
||||
|
||||
// We need to import the 'data' package as an implicit dependency.
|
||||
// See: https://godoc.org/github.com/paulrosania/go-charset/charset
|
||||
_ "github.com/paulrosania/go-charset/data"
|
||||
)
|
||||
|
||||
type Bmumble struct {
|
||||
client *gumble.Client
|
||||
Nick string
|
||||
Host string
|
||||
Channel *uint32
|
||||
local chan config.Message
|
||||
running chan error
|
||||
connected chan gumble.DisconnectEvent
|
||||
serverConfigUpdate chan gumble.ServerConfigEvent
|
||||
serverConfig gumble.ServerConfigEvent
|
||||
tlsConfig tls.Config
|
||||
|
||||
*bridge.Config
|
||||
}
|
||||
|
||||
func New(cfg *bridge.Config) bridge.Bridger {
|
||||
b := &Bmumble{}
|
||||
b.Config = cfg
|
||||
b.Nick = b.GetString("Nick")
|
||||
b.local = make(chan config.Message)
|
||||
b.running = make(chan error)
|
||||
b.connected = make(chan gumble.DisconnectEvent)
|
||||
b.serverConfigUpdate = make(chan gumble.ServerConfigEvent)
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *Bmumble) Connect() error {
|
||||
b.Log.Infof("Connecting %s", b.GetString("Server"))
|
||||
host, portstr, err := net.SplitHostPort(b.GetString("Server"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b.Host = host
|
||||
_, err = strconv.Atoi(portstr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = b.buildTLSConfig(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
go b.doSend()
|
||||
go b.connectLoop()
|
||||
err = <-b.running
|
||||
return err
|
||||
}
|
||||
|
||||
func (b *Bmumble) Disconnect() error {
|
||||
return b.client.Disconnect()
|
||||
}
|
||||
|
||||
func (b *Bmumble) JoinChannel(channel config.ChannelInfo) error {
|
||||
cid, err := strconv.ParseUint(channel.Name, 10, 32)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
channelID := uint32(cid)
|
||||
if b.Channel != nil && *b.Channel != channelID {
|
||||
b.Log.Fatalf("Cannot join channel ID '%d', already joined to channel ID %d", channelID, *b.Channel)
|
||||
return errors.New("the Mumble bridge can only join a single channel")
|
||||
}
|
||||
b.Channel = &channelID
|
||||
return b.doJoin(b.client, channelID)
|
||||
}
|
||||
|
||||
func (b *Bmumble) Send(msg config.Message) (string, error) {
|
||||
// Only process text messages
|
||||
b.Log.Debugf("=> Received local message %#v", msg)
|
||||
if msg.Event != "" && msg.Event != config.EventUserAction {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
attachments := b.extractFiles(&msg)
|
||||
b.local <- msg
|
||||
for _, a := range attachments {
|
||||
b.local <- a
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (b *Bmumble) buildTLSConfig() error {
|
||||
b.tlsConfig = tls.Config{}
|
||||
// Load TLS client certificate keypair required for registered user authentication
|
||||
if cpath := b.GetString("TLSClientCertificate"); cpath != "" {
|
||||
if ckey := b.GetString("TLSClientKey"); ckey != "" {
|
||||
cert, err := tls.LoadX509KeyPair(cpath, ckey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b.tlsConfig.Certificates = []tls.Certificate{cert}
|
||||
}
|
||||
}
|
||||
// Load TLS CA used for server verification. If not provided, the Go system trust anchor is used
|
||||
if capath := b.GetString("TLSCACertificate"); capath != "" {
|
||||
ca, err := ioutil.ReadFile(capath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b.tlsConfig.RootCAs = x509.NewCertPool()
|
||||
b.tlsConfig.RootCAs.AppendCertsFromPEM(ca)
|
||||
}
|
||||
b.tlsConfig.InsecureSkipVerify = b.GetBool("SkipTLSVerify")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bmumble) connectLoop() {
|
||||
firstConnect := true
|
||||
for {
|
||||
err := b.doConnect()
|
||||
if firstConnect {
|
||||
b.running <- err
|
||||
}
|
||||
if err != nil {
|
||||
b.Log.Errorf("Connection to server failed: %#v", err)
|
||||
if firstConnect {
|
||||
break
|
||||
} else {
|
||||
b.Log.Info("Retrying in 10s")
|
||||
time.Sleep(10 * time.Second)
|
||||
continue
|
||||
}
|
||||
}
|
||||
firstConnect = false
|
||||
d := <-b.connected
|
||||
switch d.Type {
|
||||
case gumble.DisconnectError:
|
||||
b.Log.Errorf("Lost connection to the server (%s), attempting reconnect", d.String)
|
||||
continue
|
||||
case gumble.DisconnectKicked:
|
||||
b.Log.Errorf("Kicked from the server (%s), attempting reconnect", d.String)
|
||||
continue
|
||||
case gumble.DisconnectBanned:
|
||||
b.Log.Errorf("Banned from the server (%s), not attempting reconnect", d.String)
|
||||
close(b.connected)
|
||||
close(b.running)
|
||||
return
|
||||
case gumble.DisconnectUser:
|
||||
b.Log.Infof("Disconnect successful")
|
||||
close(b.connected)
|
||||
close(b.running)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bmumble) doConnect() error {
|
||||
// Create new gumble config and attach event handlers
|
||||
gumbleConfig := gumble.NewConfig()
|
||||
gumbleConfig.Attach(gumbleutil.Listener{
|
||||
ServerConfig: b.handleServerConfig,
|
||||
TextMessage: b.handleTextMessage,
|
||||
Connect: b.handleConnect,
|
||||
Disconnect: b.handleDisconnect,
|
||||
UserChange: b.handleUserChange,
|
||||
})
|
||||
gumbleConfig.Username = b.GetString("Nick")
|
||||
if password := b.GetString("Password"); password != "" {
|
||||
gumbleConfig.Password = password
|
||||
}
|
||||
|
||||
client, err := gumble.DialWithDialer(new(net.Dialer), b.GetString("Server"), gumbleConfig, &b.tlsConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b.client = client
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bmumble) doJoin(client *gumble.Client, channelID uint32) error {
|
||||
channel, ok := client.Channels[channelID]
|
||||
if !ok {
|
||||
return fmt.Errorf("no channel with ID %d", channelID)
|
||||
}
|
||||
client.Self.Move(channel)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bmumble) doSend() {
|
||||
// Message sending loop that makes sure server-side
|
||||
// restrictions and client-side message traits don't conflict
|
||||
// with each other.
|
||||
for {
|
||||
select {
|
||||
case serverConfig := <-b.serverConfigUpdate:
|
||||
b.Log.Debugf("Received server config update: AllowHTML=%#v, MaximumMessageLength=%#v", serverConfig.AllowHTML, serverConfig.MaximumMessageLength)
|
||||
b.serverConfig = serverConfig
|
||||
case msg := <-b.local:
|
||||
b.processMessage(&msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bmumble) processMessage(msg *config.Message) {
|
||||
b.Log.Debugf("Processing message %s", msg.Text)
|
||||
|
||||
allowHTML := true
|
||||
if b.serverConfig.AllowHTML != nil {
|
||||
allowHTML = *b.serverConfig.AllowHTML
|
||||
}
|
||||
|
||||
// If this is a specially generated image message, send it unmodified
|
||||
if msg.Event == "mumble_image" {
|
||||
if allowHTML {
|
||||
b.client.Self.Channel.Send(msg.Username+msg.Text, false)
|
||||
} else {
|
||||
b.Log.Info("Can't send image, server does not allow HTML messages")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Don't process empty messages
|
||||
if len(msg.Text) == 0 {
|
||||
return
|
||||
}
|
||||
// If HTML is allowed, convert markdown into HTML, otherwise strip markdown
|
||||
if allowHTML {
|
||||
msg.Text = helper.ParseMarkdown(msg.Text)
|
||||
} else {
|
||||
msg.Text = stripmd.Strip(msg.Text)
|
||||
}
|
||||
|
||||
// If there is a maximum message length, split and truncate the lines
|
||||
var msgLines []string
|
||||
if maxLength := b.serverConfig.MaximumMessageLength; maxLength != nil {
|
||||
msgLines = helper.GetSubLines(msg.Text, *maxLength-len(msg.Username))
|
||||
} else {
|
||||
msgLines = helper.GetSubLines(msg.Text, 0)
|
||||
}
|
||||
// Send the individual lindes
|
||||
for i := range msgLines {
|
||||
b.client.Self.Channel.Send(msg.Username+msgLines[i], false)
|
||||
}
|
||||
}
|
||||
210
bridge/nctalk/nctalk.go
Normal file
210
bridge/nctalk/nctalk.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package nctalk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/42wim/matterbridge/bridge"
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
|
||||
"gomod.garykim.dev/nc-talk/ocs"
|
||||
"gomod.garykim.dev/nc-talk/room"
|
||||
"gomod.garykim.dev/nc-talk/user"
|
||||
)
|
||||
|
||||
type Btalk struct {
|
||||
user *user.TalkUser
|
||||
rooms []Broom
|
||||
*bridge.Config
|
||||
}
|
||||
|
||||
func New(cfg *bridge.Config) bridge.Bridger {
|
||||
return &Btalk{Config: cfg}
|
||||
}
|
||||
|
||||
type Broom struct {
|
||||
room *room.TalkRoom
|
||||
ctx context.Context
|
||||
ctxCancel context.CancelFunc
|
||||
}
|
||||
|
||||
func (b *Btalk) Connect() error {
|
||||
b.Log.Info("Connecting")
|
||||
tconfig := &user.TalkUserConfig{
|
||||
TLSConfig: &tls.Config{
|
||||
InsecureSkipVerify: b.GetBool("SkipTLSVerify"), //nolint:gosec
|
||||
},
|
||||
}
|
||||
var err error
|
||||
b.user, err = user.NewUser(b.GetString("Server"), b.GetString("Login"), b.GetString("Password"), tconfig)
|
||||
if err != nil {
|
||||
b.Log.Error("Config could not be used")
|
||||
return err
|
||||
}
|
||||
_, err = b.user.Capabilities()
|
||||
if err != nil {
|
||||
b.Log.Error("Cannot Connect")
|
||||
return err
|
||||
}
|
||||
b.Log.Info("Connected")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Btalk) Disconnect() error {
|
||||
for _, r := range b.rooms {
|
||||
r.ctxCancel()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Btalk) JoinChannel(channel config.ChannelInfo) error {
|
||||
tr, err := room.NewTalkRoom(b.user, channel.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newRoom := Broom{
|
||||
room: tr,
|
||||
}
|
||||
newRoom.ctx, newRoom.ctxCancel = context.WithCancel(context.Background())
|
||||
c, err := newRoom.room.ReceiveMessages(newRoom.ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b.rooms = append(b.rooms, newRoom)
|
||||
|
||||
// Config
|
||||
guestSuffix := " (Guest)"
|
||||
if b.IsKeySet("GuestSuffix") {
|
||||
guestSuffix = b.GetString("GuestSuffix")
|
||||
}
|
||||
|
||||
go func() {
|
||||
for msg := range c {
|
||||
msg := msg
|
||||
|
||||
if msg.Error != nil {
|
||||
b.Log.Errorf("Fatal message poll error: %s\n", msg.Error)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// ignore messages that are one of the following
|
||||
// * not a message from a user
|
||||
// * from ourselves
|
||||
if msg.MessageType != ocs.MessageComment || msg.ActorID == b.user.User {
|
||||
continue
|
||||
}
|
||||
remoteMessage := config.Message{
|
||||
Text: formatRichObjectString(msg.Message, msg.MessageParameters),
|
||||
Channel: newRoom.room.Token,
|
||||
Username: DisplayName(msg, guestSuffix),
|
||||
UserID: msg.ActorID,
|
||||
Account: b.Account,
|
||||
}
|
||||
// It is possible for the ID to not be set on older versions of Talk so we only set it if
|
||||
// the ID is not blank
|
||||
if msg.ID != 0 {
|
||||
remoteMessage.ID = strconv.Itoa(msg.ID)
|
||||
}
|
||||
|
||||
// Handle Files
|
||||
err = b.handleFiles(&remoteMessage, &msg)
|
||||
if err != nil {
|
||||
b.Log.Errorf("Error handling file: %#v", msg)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
b.Log.Debugf("<= Message is %#v", remoteMessage)
|
||||
b.Remote <- remoteMessage
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Btalk) Send(msg config.Message) (string, error) {
|
||||
r := b.getRoom(msg.Channel)
|
||||
if r == nil {
|
||||
b.Log.Errorf("Could not find room for %v", msg.Channel)
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Talk currently only supports sending normal messages
|
||||
if msg.Event != "" {
|
||||
return "", nil
|
||||
}
|
||||
sentMessage, err := r.room.SendMessage(msg.Username + msg.Text)
|
||||
if err != nil {
|
||||
b.Log.Errorf("Could not send message to room %v from %v: %v", msg.Channel, msg.Username, err)
|
||||
return "", nil
|
||||
}
|
||||
return strconv.Itoa(sentMessage.ID), nil
|
||||
}
|
||||
|
||||
func (b *Btalk) getRoom(token string) *Broom {
|
||||
for _, r := range b.rooms {
|
||||
if r.room.Token == token {
|
||||
return &r
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Btalk) handleFiles(mmsg *config.Message, message *ocs.TalkRoomMessageData) error {
|
||||
for _, parameter := range message.MessageParameters {
|
||||
if parameter.Type == ocs.ROSTypeFile {
|
||||
// Get the file
|
||||
file, err := b.user.DownloadFile(parameter.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if mmsg.Extra == nil {
|
||||
mmsg.Extra = make(map[string][]interface{})
|
||||
}
|
||||
|
||||
mmsg.Extra["file"] = append(mmsg.Extra["file"], config.FileInfo{
|
||||
Name: parameter.Name,
|
||||
Data: file,
|
||||
Size: int64(len(*file)),
|
||||
Avatar: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Spec: https://github.com/nextcloud/server/issues/1706#issue-182308785
|
||||
func formatRichObjectString(message string, parameters map[string]ocs.RichObjectString) string {
|
||||
for id, parameter := range parameters {
|
||||
text := parameter.Name
|
||||
|
||||
switch parameter.Type {
|
||||
case ocs.ROSTypeUser, ocs.ROSTypeGroup:
|
||||
text = "@" + text
|
||||
case ocs.ROSTypeFile:
|
||||
if parameter.Link != "" {
|
||||
text = parameter.Name
|
||||
}
|
||||
}
|
||||
|
||||
message = strings.ReplaceAll(message, "{"+id+"}", text)
|
||||
}
|
||||
|
||||
return message
|
||||
}
|
||||
|
||||
func DisplayName(msg ocs.TalkRoomMessageData, suffix string) string {
|
||||
if msg.ActorType == ocs.ActorGuest {
|
||||
if msg.ActorDisplayName == "" {
|
||||
return "Guest"
|
||||
}
|
||||
|
||||
return msg.ActorDisplayName + suffix
|
||||
}
|
||||
|
||||
return msg.ActorDisplayName
|
||||
}
|
||||
97
bridge/rocketchat/handlers.go
Normal file
97
bridge/rocketchat/handlers.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package brocketchat
|
||||
|
||||
import (
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
"github.com/matterbridge/Rocket.Chat.Go.SDK/models"
|
||||
)
|
||||
|
||||
func (b *Brocketchat) handleRocket() {
|
||||
messages := make(chan *config.Message)
|
||||
if b.GetString("WebhookBindAddress") != "" {
|
||||
b.Log.Debugf("Choosing webhooks based receiving")
|
||||
go b.handleRocketHook(messages)
|
||||
} else {
|
||||
b.Log.Debugf("Choosing login/password based receiving")
|
||||
go b.handleRocketClient(messages)
|
||||
}
|
||||
for message := range messages {
|
||||
message.Account = b.Account
|
||||
b.Log.Debugf("<= Sending message from %s on %s to gateway", message.Username, b.Account)
|
||||
b.Log.Debugf("<= Message is %#v", message)
|
||||
b.Remote <- *message
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Brocketchat) handleRocketHook(messages chan *config.Message) {
|
||||
for {
|
||||
message := b.rh.Receive()
|
||||
b.Log.Debugf("Receiving from rockethook %#v", message)
|
||||
// do not loop
|
||||
if message.UserName == b.GetString("Nick") {
|
||||
continue
|
||||
}
|
||||
messages <- &config.Message{
|
||||
UserID: message.UserID,
|
||||
Username: message.UserName,
|
||||
Text: message.Text,
|
||||
Channel: message.ChannelName,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Brocketchat) handleStatusEvent(ev models.Message, rmsg *config.Message) bool {
|
||||
switch ev.Type {
|
||||
case "":
|
||||
// this is a normal message, no processing needed
|
||||
// return true so the message is not dropped
|
||||
return true
|
||||
case sUserJoined, sUserLeft:
|
||||
rmsg.Event = config.EventJoinLeave
|
||||
return true
|
||||
case sRoomChangedTopic:
|
||||
rmsg.Event = config.EventTopicChange
|
||||
return true
|
||||
}
|
||||
b.Log.Debugf("Dropping message with unknown type: %s", ev.Type)
|
||||
return false
|
||||
}
|
||||
|
||||
func (b *Brocketchat) handleRocketClient(messages chan *config.Message) {
|
||||
for message := range b.messageChan {
|
||||
// skip messages with same ID, apparently messages get duplicated for an unknown reason
|
||||
if _, ok := b.cache.Get(message.ID); ok {
|
||||
continue
|
||||
}
|
||||
b.cache.Add(message.ID, true)
|
||||
b.Log.Debugf("message %#v", message)
|
||||
m := message
|
||||
if b.skipMessage(&m) {
|
||||
b.Log.Debugf("Skipped message: %#v", message)
|
||||
continue
|
||||
}
|
||||
|
||||
rmsg := &config.Message{Text: message.Msg,
|
||||
Username: message.User.UserName,
|
||||
Channel: b.getChannelName(message.RoomID),
|
||||
Account: b.Account,
|
||||
UserID: message.User.ID,
|
||||
ID: message.ID,
|
||||
}
|
||||
|
||||
// handleStatusEvent returns false if the message should be dropped
|
||||
// in that case it is probably some modification to the channel we do not want to relay
|
||||
if b.handleStatusEvent(m, rmsg) {
|
||||
messages <- rmsg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Brocketchat) handleUploadFile(msg *config.Message) error {
|
||||
for _, f := range msg.Extra["file"] {
|
||||
fi := f.(config.FileInfo)
|
||||
if err := b.uploadFile(&fi, b.getChannelID(msg.Channel)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
201
bridge/rocketchat/helpers.go
Normal file
201
bridge/rocketchat/helpers.go
Normal file
@@ -0,0 +1,201 @@
|
||||
package brocketchat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io/ioutil"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
"github.com/42wim/matterbridge/bridge/helper"
|
||||
"github.com/42wim/matterbridge/hook/rockethook"
|
||||
"github.com/42wim/matterbridge/matterhook"
|
||||
"github.com/matterbridge/Rocket.Chat.Go.SDK/models"
|
||||
"github.com/matterbridge/Rocket.Chat.Go.SDK/realtime"
|
||||
"github.com/matterbridge/Rocket.Chat.Go.SDK/rest"
|
||||
"github.com/nelsonken/gomf"
|
||||
)
|
||||
|
||||
func (b *Brocketchat) doConnectWebhookBind() error {
|
||||
switch {
|
||||
case b.GetString("WebhookURL") != "":
|
||||
b.Log.Info("Connecting using webhookurl (sending) and webhookbindaddress (receiving)")
|
||||
b.mh = matterhook.New(b.GetString("WebhookURL"),
|
||||
matterhook.Config{InsecureSkipVerify: b.GetBool("SkipTLSVerify"),
|
||||
DisableServer: true})
|
||||
b.rh = rockethook.New(b.GetString("WebhookURL"), rockethook.Config{BindAddress: b.GetString("WebhookBindAddress")})
|
||||
case b.GetString("Login") != "":
|
||||
b.Log.Info("Connecting using login/password (sending)")
|
||||
err := b.apiLogin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
b.Log.Info("Connecting using webhookbindaddress (receiving)")
|
||||
b.rh = rockethook.New(b.GetString("WebhookURL"), rockethook.Config{BindAddress: b.GetString("WebhookBindAddress")})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Brocketchat) doConnectWebhookURL() error {
|
||||
b.Log.Info("Connecting using webhookurl (sending)")
|
||||
b.mh = matterhook.New(b.GetString("WebhookURL"),
|
||||
matterhook.Config{InsecureSkipVerify: b.GetBool("SkipTLSVerify"),
|
||||
DisableServer: true})
|
||||
if b.GetString("Login") != "" {
|
||||
b.Log.Info("Connecting using login/password (receiving)")
|
||||
err := b.apiLogin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Brocketchat) apiLogin() error {
|
||||
b.Log.Debugf("handling apiLogin()")
|
||||
credentials := &models.UserCredentials{Email: b.GetString("login"), Password: b.GetString("password")}
|
||||
if b.GetString("Token") != "" {
|
||||
credentials = &models.UserCredentials{ID: b.GetString("Login"), Token: b.GetString("Token")}
|
||||
}
|
||||
myURL, err := url.Parse(b.GetString("server"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := realtime.NewClient(myURL, b.GetBool("debug"))
|
||||
b.c = client
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
restclient := rest.NewClient(myURL, b.GetBool("debug"))
|
||||
user, err := b.c.Login(credentials)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b.user = user
|
||||
b.r = restclient
|
||||
err = b.r.Login(credentials)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b.Log.Info("Connection succeeded")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Brocketchat) getChannelName(id string) string {
|
||||
b.RLock()
|
||||
defer b.RUnlock()
|
||||
if name, ok := b.channelMap[id]; ok {
|
||||
return name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (b *Brocketchat) getChannelID(name string) string {
|
||||
b.RLock()
|
||||
defer b.RUnlock()
|
||||
for k, v := range b.channelMap {
|
||||
if v == name || v == "#"+name {
|
||||
return k
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (b *Brocketchat) skipMessage(message *models.Message) bool {
|
||||
return message.User.ID == b.user.ID
|
||||
}
|
||||
|
||||
func (b *Brocketchat) uploadFile(fi *config.FileInfo, channel string) error {
|
||||
fb := gomf.New()
|
||||
if err := fb.WriteField("description", fi.Comment); err != nil {
|
||||
return err
|
||||
}
|
||||
sp := strings.Split(fi.Name, ".")
|
||||
mtype := mime.TypeByExtension("." + sp[len(sp)-1])
|
||||
if !strings.Contains(mtype, "image") && !strings.Contains(mtype, "video") {
|
||||
return nil
|
||||
}
|
||||
if err := fb.WriteFile("file", fi.Name, mtype, *fi.Data); err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := fb.GetHTTPRequest(context.TODO(), b.GetString("server")+"/api/v1/rooms.upload/"+channel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Add("X-Auth-Token", b.user.Token)
|
||||
req.Header.Add("X-User-Id", b.user.ID)
|
||||
client := &http.Client{
|
||||
Timeout: time.Second * 5,
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
b.Log.Errorf("failed: %#v", string(body))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendWebhook uses the configured WebhookURL to send the message
|
||||
func (b *Brocketchat) sendWebhook(msg *config.Message) error {
|
||||
// skip events
|
||||
if msg.Event != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if b.GetBool("PrefixMessagesWithNick") {
|
||||
msg.Text = msg.Username + msg.Text
|
||||
}
|
||||
if msg.Extra != nil {
|
||||
// this sends a message only if we received a config.EVENT_FILE_FAILURE_SIZE
|
||||
for _, rmsg := range helper.HandleExtra(msg, b.General) {
|
||||
rmsg := rmsg // scopelint
|
||||
iconURL := config.GetIconURL(&rmsg, b.GetString("iconurl"))
|
||||
matterMessage := matterhook.OMessage{
|
||||
IconURL: iconURL,
|
||||
Channel: rmsg.Channel,
|
||||
UserName: rmsg.Username,
|
||||
Text: rmsg.Text,
|
||||
Props: make(map[string]interface{}),
|
||||
}
|
||||
if err := b.mh.Send(matterMessage); err != nil {
|
||||
b.Log.Errorf("sendWebhook failed: %s ", err)
|
||||
}
|
||||
}
|
||||
|
||||
// webhook doesn't support file uploads, so we add the url manually
|
||||
if len(msg.Extra["file"]) > 0 {
|
||||
for _, f := range msg.Extra["file"] {
|
||||
fi := f.(config.FileInfo)
|
||||
if fi.URL != "" {
|
||||
msg.Text += fi.URL
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
iconURL := config.GetIconURL(msg, b.GetString("iconurl"))
|
||||
matterMessage := matterhook.OMessage{
|
||||
IconURL: iconURL,
|
||||
Channel: msg.Channel,
|
||||
UserName: msg.Username,
|
||||
Text: msg.Text,
|
||||
}
|
||||
if msg.Avatar != "" {
|
||||
matterMessage.IconURL = msg.Avatar
|
||||
}
|
||||
err := b.mh.Send(matterMessage)
|
||||
if err != nil {
|
||||
b.Log.Info(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,36 +1,52 @@
|
||||
package brocketchat
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/42wim/matterbridge/bridge"
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
"github.com/42wim/matterbridge/bridge/helper"
|
||||
"github.com/42wim/matterbridge/hook/rockethook"
|
||||
"github.com/42wim/matterbridge/matterhook"
|
||||
log "github.com/Sirupsen/logrus"
|
||||
lru "github.com/hashicorp/golang-lru"
|
||||
"github.com/matterbridge/Rocket.Chat.Go.SDK/models"
|
||||
"github.com/matterbridge/Rocket.Chat.Go.SDK/realtime"
|
||||
"github.com/matterbridge/Rocket.Chat.Go.SDK/rest"
|
||||
)
|
||||
|
||||
type MMhook struct {
|
||||
mh *matterhook.Client
|
||||
rh *rockethook.Client
|
||||
}
|
||||
|
||||
type Brocketchat struct {
|
||||
MMhook
|
||||
Config *config.Protocol
|
||||
Remote chan config.Message
|
||||
Account string
|
||||
mh *matterhook.Client
|
||||
rh *rockethook.Client
|
||||
c *realtime.Client
|
||||
r *rest.Client
|
||||
cache *lru.Cache
|
||||
*bridge.Config
|
||||
messageChan chan models.Message
|
||||
channelMap map[string]string
|
||||
user *models.User
|
||||
sync.RWMutex
|
||||
}
|
||||
|
||||
var flog *log.Entry
|
||||
var protocol = "rocketchat"
|
||||
const (
|
||||
sUserJoined = "uj"
|
||||
sUserLeft = "ul"
|
||||
sRoomChangedTopic = "room_changed_topic"
|
||||
)
|
||||
|
||||
func init() {
|
||||
flog = log.WithFields(log.Fields{"module": protocol})
|
||||
}
|
||||
|
||||
func New(cfg config.Protocol, account string, c chan config.Message) *Brocketchat {
|
||||
b := &Brocketchat{}
|
||||
b.Config = &cfg
|
||||
b.Remote = c
|
||||
b.Account = account
|
||||
func New(cfg *bridge.Config) bridge.Bridger {
|
||||
newCache, err := lru.New(100)
|
||||
if err != nil {
|
||||
cfg.Log.Fatalf("Could not create LRU cache for rocketchat bridge: %v", err)
|
||||
}
|
||||
b := &Brocketchat{
|
||||
Config: cfg,
|
||||
messageChan: make(chan models.Message),
|
||||
channelMap: make(map[string]string),
|
||||
cache: newCache,
|
||||
}
|
||||
b.Log.Debugf("enabling rocketchat")
|
||||
return b
|
||||
}
|
||||
|
||||
@@ -39,52 +55,127 @@ func (b *Brocketchat) Command(cmd string) string {
|
||||
}
|
||||
|
||||
func (b *Brocketchat) Connect() error {
|
||||
flog.Info("Connecting webhooks")
|
||||
b.mh = matterhook.New(b.Config.WebhookURL,
|
||||
matterhook.Config{InsecureSkipVerify: b.Config.SkipTLSVerify,
|
||||
DisableServer: true})
|
||||
b.rh = rockethook.New(b.Config.WebhookURL, rockethook.Config{BindAddress: b.Config.WebhookBindAddress})
|
||||
go b.handleRocketHook()
|
||||
if b.GetString("WebhookBindAddress") != "" {
|
||||
if err := b.doConnectWebhookBind(); err != nil {
|
||||
return err
|
||||
}
|
||||
go b.handleRocket()
|
||||
return nil
|
||||
}
|
||||
switch {
|
||||
case b.GetString("WebhookURL") != "":
|
||||
if err := b.doConnectWebhookURL(); err != nil {
|
||||
return err
|
||||
}
|
||||
go b.handleRocket()
|
||||
return nil
|
||||
case b.GetString("Login") != "":
|
||||
b.Log.Info("Connecting using login/password (sending and receiving)")
|
||||
err := b.apiLogin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
go b.handleRocket()
|
||||
}
|
||||
if b.GetString("WebhookBindAddress") == "" && b.GetString("WebhookURL") == "" &&
|
||||
b.GetString("Login") == "" {
|
||||
return errors.New("no connection method found. See that you have WebhookBindAddress, WebhookURL or Login/Password/Server configured")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Brocketchat) Disconnect() error {
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
func (b *Brocketchat) JoinChannel(channel config.ChannelInfo) error {
|
||||
if b.c == nil {
|
||||
return nil
|
||||
}
|
||||
id, err := b.c.GetChannelId(strings.TrimPrefix(channel.Name, "#"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b.Lock()
|
||||
b.channelMap[id] = channel.Name
|
||||
b.Unlock()
|
||||
mychannel := &models.Channel{ID: id, Name: strings.TrimPrefix(channel.Name, "#")}
|
||||
if err := b.c.JoinChannel(id); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := b.c.SubscribeToMessageStream(mychannel, b.messageChan); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Brocketchat) Send(msg config.Message) (string, error) {
|
||||
// ignore delete messages
|
||||
if msg.Event == config.EVENT_MSG_DELETE {
|
||||
return "", nil
|
||||
// strip the # if people has set this
|
||||
msg.Channel = strings.TrimPrefix(msg.Channel, "#")
|
||||
channel := &models.Channel{ID: b.getChannelID(msg.Channel), Name: msg.Channel}
|
||||
|
||||
// Make a action /me of the message
|
||||
if msg.Event == config.EventUserAction {
|
||||
msg.Text = "_" + msg.Text + "_"
|
||||
}
|
||||
flog.Debugf("Receiving %#v", msg)
|
||||
matterMessage := matterhook.OMessage{IconURL: b.Config.IconURL}
|
||||
matterMessage.Channel = msg.Channel
|
||||
matterMessage.UserName = msg.Username
|
||||
matterMessage.Type = ""
|
||||
matterMessage.Text = msg.Text
|
||||
err := b.mh.Send(matterMessage)
|
||||
if err != nil {
|
||||
flog.Info(err)
|
||||
|
||||
// Delete message
|
||||
if msg.Event == config.EventMsgDelete {
|
||||
if msg.ID == "" {
|
||||
return "", nil
|
||||
}
|
||||
return msg.ID, b.c.DeleteMessage(&models.Message{ID: msg.ID})
|
||||
}
|
||||
|
||||
// Use webhook to send the message
|
||||
if b.GetString("WebhookURL") != "" {
|
||||
return "", b.sendWebhook(&msg)
|
||||
}
|
||||
|
||||
// Prepend nick if configured
|
||||
if b.GetBool("PrefixMessagesWithNick") {
|
||||
msg.Text = msg.Username + msg.Text
|
||||
}
|
||||
|
||||
// Edit message if we have an ID
|
||||
if msg.ID != "" {
|
||||
return msg.ID, b.c.EditMessage(&models.Message{ID: msg.ID, Msg: msg.Text, RoomID: b.getChannelID(msg.Channel)})
|
||||
}
|
||||
|
||||
// Upload a file if it exists
|
||||
if msg.Extra != nil {
|
||||
for _, rmsg := range helper.HandleExtra(&msg, b.General) {
|
||||
// strip the # if people has set this
|
||||
rmsg.Channel = strings.TrimPrefix(rmsg.Channel, "#")
|
||||
smsg := &models.Message{
|
||||
RoomID: b.getChannelID(rmsg.Channel),
|
||||
Msg: rmsg.Username + rmsg.Text,
|
||||
PostMessage: models.PostMessage{
|
||||
Avatar: rmsg.Avatar,
|
||||
Alias: rmsg.Username,
|
||||
},
|
||||
}
|
||||
if _, err := b.c.SendMessage(smsg); err != nil {
|
||||
b.Log.Errorf("SendMessage failed: %s", err)
|
||||
}
|
||||
}
|
||||
if len(msg.Extra["file"]) > 0 {
|
||||
return "", b.handleUploadFile(&msg)
|
||||
}
|
||||
}
|
||||
|
||||
smsg := &models.Message{
|
||||
RoomID: channel.ID,
|
||||
Msg: msg.Text,
|
||||
PostMessage: models.PostMessage{
|
||||
Avatar: msg.Avatar,
|
||||
Alias: msg.Username,
|
||||
},
|
||||
}
|
||||
|
||||
rmsg, err := b.c.SendMessage(smsg)
|
||||
if rmsg == nil {
|
||||
return "", err
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (b *Brocketchat) handleRocketHook() {
|
||||
for {
|
||||
message := b.rh.Receive()
|
||||
flog.Debugf("Receiving from rockethook %#v", message)
|
||||
// do not loop
|
||||
if message.UserName == b.Config.Nick {
|
||||
continue
|
||||
}
|
||||
flog.Debugf("Sending message from %s on %s to gateway", message.UserName, b.Account)
|
||||
b.Remote <- config.Message{Text: message.Text, Username: message.UserName, Channel: message.ChannelName, Account: b.Account, UserID: message.UserID}
|
||||
}
|
||||
return rmsg.ID, err
|
||||
}
|
||||
|
||||
373
bridge/slack/handlers.go
Normal file
373
bridge/slack/handlers.go
Normal file
@@ -0,0 +1,373 @@
|
||||
package bslack
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"time"
|
||||
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
"github.com/42wim/matterbridge/bridge/helper"
|
||||
"github.com/slack-go/slack"
|
||||
)
|
||||
|
||||
// ErrEventIgnored is for events that should be ignored
|
||||
var ErrEventIgnored = errors.New("this event message should ignored")
|
||||
|
||||
func (b *Bslack) handleSlack() {
|
||||
messages := make(chan *config.Message)
|
||||
if b.GetString(incomingWebhookConfig) != "" && b.GetString(tokenConfig) == "" {
|
||||
b.Log.Debugf("Choosing webhooks based receiving")
|
||||
go b.handleMatterHook(messages)
|
||||
} else {
|
||||
b.Log.Debugf("Choosing token based receiving")
|
||||
go b.handleSlackClient(messages)
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
b.Log.Debug("Start listening for Slack messages")
|
||||
for message := range messages {
|
||||
// don't do any action on deleted/typing messages
|
||||
if message.Event != config.EventUserTyping && message.Event != config.EventMsgDelete {
|
||||
b.Log.Debugf("<= Sending message from %s on %s to gateway", message.Username, b.Account)
|
||||
// cleanup the message
|
||||
message.Text = b.replaceMention(message.Text)
|
||||
message.Text = b.replaceVariable(message.Text)
|
||||
message.Text = b.replaceChannel(message.Text)
|
||||
message.Text = b.replaceURL(message.Text)
|
||||
message.Text = b.replaceb0rkedMarkDown(message.Text)
|
||||
message.Text = html.UnescapeString(message.Text)
|
||||
|
||||
// Add the avatar
|
||||
message.Avatar = b.users.getAvatar(message.UserID)
|
||||
}
|
||||
|
||||
b.Log.Debugf("<= Message is %#v", message)
|
||||
b.Remote <- *message
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bslack) handleSlackClient(messages chan *config.Message) {
|
||||
for msg := range b.rtm.IncomingEvents {
|
||||
if msg.Type != sUserTyping && msg.Type != sHello && msg.Type != sLatencyReport {
|
||||
b.Log.Debugf("== Receiving event %#v", msg.Data)
|
||||
}
|
||||
switch ev := msg.Data.(type) {
|
||||
case *slack.UserTypingEvent:
|
||||
if !b.GetBool("ShowUserTyping") {
|
||||
continue
|
||||
}
|
||||
rmsg, err := b.handleTypingEvent(ev)
|
||||
if err == ErrEventIgnored {
|
||||
continue
|
||||
} else if err != nil {
|
||||
b.Log.Errorf("%#v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
messages <- rmsg
|
||||
case *slack.MessageEvent:
|
||||
if b.skipMessageEvent(ev) {
|
||||
b.Log.Debugf("Skipped message: %#v", ev)
|
||||
continue
|
||||
}
|
||||
rmsg, err := b.handleMessageEvent(ev)
|
||||
if err != nil {
|
||||
b.Log.Errorf("%#v", err)
|
||||
continue
|
||||
}
|
||||
messages <- rmsg
|
||||
case *slack.OutgoingErrorEvent:
|
||||
b.Log.Debugf("%#v", ev.Error())
|
||||
case *slack.ChannelJoinedEvent:
|
||||
// When we join a channel we update the full list of users as
|
||||
// well as the information for the channel that we joined as this
|
||||
// should now tell that we are a member of it.
|
||||
b.channels.registerChannel(ev.Channel)
|
||||
case *slack.ConnectedEvent:
|
||||
b.si = ev.Info
|
||||
b.channels.populateChannels(true)
|
||||
b.users.populateUsers(true)
|
||||
case *slack.InvalidAuthEvent:
|
||||
b.Log.Fatalf("Invalid Token %#v", ev)
|
||||
case *slack.ConnectionErrorEvent:
|
||||
b.Log.Errorf("Connection failed %#v %#v", ev.Error(), ev.ErrorObj)
|
||||
case *slack.MemberJoinedChannelEvent:
|
||||
b.users.populateUser(ev.User)
|
||||
case *slack.HelloEvent, *slack.LatencyReport, *slack.ConnectingEvent:
|
||||
continue
|
||||
default:
|
||||
b.Log.Debugf("Unhandled incoming event: %T", ev)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bslack) handleMatterHook(messages chan *config.Message) {
|
||||
for {
|
||||
message := b.mh.Receive()
|
||||
b.Log.Debugf("receiving from matterhook (slack) %#v", message)
|
||||
if message.UserName == "slackbot" {
|
||||
continue
|
||||
}
|
||||
messages <- &config.Message{
|
||||
Username: message.UserName,
|
||||
Text: message.Text,
|
||||
Channel: message.ChannelName,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// skipMessageEvent skips event that need to be skipped :-)
|
||||
func (b *Bslack) skipMessageEvent(ev *slack.MessageEvent) bool {
|
||||
switch ev.SubType {
|
||||
case sChannelLeave, sChannelJoin:
|
||||
return b.GetBool(noSendJoinConfig)
|
||||
case sPinnedItem, sUnpinnedItem:
|
||||
return true
|
||||
case sChannelTopic, sChannelPurpose:
|
||||
// Skip the event if our bot/user account changed the topic/purpose
|
||||
if ev.User == b.si.User.ID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check for our callback ID
|
||||
hasOurCallbackID := false
|
||||
if len(ev.Blocks.BlockSet) == 1 {
|
||||
block, ok := ev.Blocks.BlockSet[0].(*slack.SectionBlock)
|
||||
hasOurCallbackID = ok && block.BlockID == "matterbridge_"+b.uuid
|
||||
}
|
||||
|
||||
if ev.SubMessage != nil {
|
||||
// It seems ev.SubMessage.Edited == nil when slack unfurls.
|
||||
// Do not forward these messages. See Github issue #266.
|
||||
if ev.SubMessage.ThreadTimestamp != ev.SubMessage.Timestamp &&
|
||||
ev.SubMessage.Edited == nil {
|
||||
return true
|
||||
}
|
||||
// see hidden subtypes at https://api.slack.com/events/message
|
||||
// these messages are sent when we add a message to a thread #709
|
||||
if ev.SubType == "message_replied" && ev.Hidden {
|
||||
return true
|
||||
}
|
||||
if len(ev.SubMessage.Blocks.BlockSet) == 1 {
|
||||
block, ok := ev.SubMessage.Blocks.BlockSet[0].(*slack.SectionBlock)
|
||||
hasOurCallbackID = ok && block.BlockID == "matterbridge_"+b.uuid
|
||||
}
|
||||
}
|
||||
|
||||
// Skip any messages that we made ourselves or from 'slackbot' (see #527).
|
||||
if ev.Username == sSlackBotUser ||
|
||||
(b.rtm != nil && ev.Username == b.si.User.Name) || hasOurCallbackID {
|
||||
return true
|
||||
}
|
||||
|
||||
if len(ev.Files) > 0 {
|
||||
return b.filesCached(ev.Files)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (b *Bslack) filesCached(files []slack.File) bool {
|
||||
for i := range files {
|
||||
if !b.fileCached(&files[i]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// handleMessageEvent handles the message events. Together with any called sub-methods,
|
||||
// this method implements the following event processing pipeline:
|
||||
//
|
||||
// 1. Check if the message should be ignored.
|
||||
// NOTE: This is not actually part of the method below but is done just before it
|
||||
// is called via the 'skipMessageEvent()' method.
|
||||
// 2. Populate the Matterbridge message that will be sent to the router based on the
|
||||
// received event and logic that is common to all events that are not skipped.
|
||||
// 3. Detect and handle any message that is "status" related (think join channel, etc.).
|
||||
// This might result in an early exit from the pipeline and passing of the
|
||||
// pre-populated message to the Matterbridge router.
|
||||
// 4. Handle the specific case of messages that edit existing messages depending on
|
||||
// configuration.
|
||||
// 5. Handle any attachments of the received event.
|
||||
// 6. Check that the Matterbridge message that we end up with after at the end of the
|
||||
// pipeline is valid before sending it to the Matterbridge router.
|
||||
func (b *Bslack) handleMessageEvent(ev *slack.MessageEvent) (*config.Message, error) {
|
||||
rmsg, err := b.populateReceivedMessage(ev)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Handle some message types early.
|
||||
if b.handleStatusEvent(ev, rmsg) {
|
||||
return rmsg, nil
|
||||
}
|
||||
|
||||
b.handleAttachments(ev, rmsg)
|
||||
|
||||
// Verify that we have the right information and the message
|
||||
// is well-formed before sending it out to the router.
|
||||
if len(ev.Files) == 0 && (rmsg.Text == "" || rmsg.Username == "") {
|
||||
if ev.BotID != "" {
|
||||
// This is probably a webhook we couldn't resolve.
|
||||
return nil, fmt.Errorf("message handling resulted in an empty bot message (probably an incoming webhook we couldn't resolve): %#v", ev)
|
||||
}
|
||||
if ev.SubMessage != nil {
|
||||
return nil, fmt.Errorf("message handling resulted in an empty message: %#v with submessage %#v", ev, ev.SubMessage)
|
||||
}
|
||||
return nil, fmt.Errorf("message handling resulted in an empty message: %#v", ev)
|
||||
}
|
||||
return rmsg, nil
|
||||
}
|
||||
|
||||
func (b *Bslack) handleStatusEvent(ev *slack.MessageEvent, rmsg *config.Message) bool {
|
||||
switch ev.SubType {
|
||||
case sChannelJoined, sMemberJoined:
|
||||
// There's no further processing needed on channel events
|
||||
// so we return 'true'.
|
||||
return true
|
||||
case sChannelJoin, sChannelLeave:
|
||||
rmsg.Username = sSystemUser
|
||||
rmsg.Event = config.EventJoinLeave
|
||||
case sChannelTopic, sChannelPurpose:
|
||||
b.channels.populateChannels(false)
|
||||
rmsg.Event = config.EventTopicChange
|
||||
case sMessageChanged:
|
||||
rmsg.Text = ev.SubMessage.Text
|
||||
// handle deleted thread starting messages
|
||||
if ev.SubMessage.Text == "This message was deleted." {
|
||||
rmsg.Event = config.EventMsgDelete
|
||||
return true
|
||||
}
|
||||
case sMessageDeleted:
|
||||
rmsg.Text = config.EventMsgDelete
|
||||
rmsg.Event = config.EventMsgDelete
|
||||
rmsg.ID = ev.DeletedTimestamp
|
||||
// If a message is being deleted we do not need to process
|
||||
// the event any further so we return 'true'.
|
||||
return true
|
||||
case sMeMessage:
|
||||
rmsg.Event = config.EventUserAction
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (b *Bslack) handleAttachments(ev *slack.MessageEvent, rmsg *config.Message) {
|
||||
// File comments are set by the system (because there is no username given).
|
||||
if ev.SubType == sFileComment {
|
||||
rmsg.Username = sSystemUser
|
||||
}
|
||||
|
||||
// See if we have some text in the attachments.
|
||||
if rmsg.Text == "" {
|
||||
for _, attach := range ev.Attachments {
|
||||
if attach.Text != "" {
|
||||
if attach.Title != "" {
|
||||
rmsg.Text = attach.Title + "\n"
|
||||
}
|
||||
rmsg.Text += attach.Text
|
||||
} else {
|
||||
rmsg.Text = attach.Fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save the attachments, so that we can send them to other slack (compatible) bridges.
|
||||
if len(ev.Attachments) > 0 {
|
||||
rmsg.Extra[sSlackAttachment] = append(rmsg.Extra[sSlackAttachment], ev.Attachments)
|
||||
}
|
||||
|
||||
// If we have files attached, download them (in memory) and put a pointer to it in msg.Extra.
|
||||
for i := range ev.Files {
|
||||
if err := b.handleDownloadFile(rmsg, &ev.Files[i], false); err != nil {
|
||||
b.Log.Errorf("Could not download incoming file: %#v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bslack) handleTypingEvent(ev *slack.UserTypingEvent) (*config.Message, error) {
|
||||
if ev.User == b.si.User.ID {
|
||||
return nil, ErrEventIgnored
|
||||
}
|
||||
channelInfo, err := b.channels.getChannelByID(ev.Channel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &config.Message{
|
||||
Channel: channelInfo.Name,
|
||||
Account: b.Account,
|
||||
Event: config.EventUserTyping,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// handleDownloadFile handles file download
|
||||
func (b *Bslack) handleDownloadFile(rmsg *config.Message, file *slack.File, retry bool) error {
|
||||
if b.fileCached(file) {
|
||||
return nil
|
||||
}
|
||||
// Check that the file is neither too large nor blacklisted.
|
||||
if err := helper.HandleDownloadSize(b.Log, rmsg, file.Name, int64(file.Size), b.General); err != nil {
|
||||
b.Log.WithError(err).Infof("Skipping download of incoming file.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Actually download the file.
|
||||
data, err := helper.DownloadFileAuth(file.URLPrivateDownload, "Bearer "+b.GetString(tokenConfig))
|
||||
if err != nil {
|
||||
return fmt.Errorf("download %s failed %#v", file.URLPrivateDownload, err)
|
||||
}
|
||||
|
||||
if len(*data) != file.Size && !retry {
|
||||
b.Log.Debugf("Data size (%d) is not equal to size declared (%d)\n", len(*data), file.Size)
|
||||
time.Sleep(1 * time.Second)
|
||||
return b.handleDownloadFile(rmsg, file, true)
|
||||
}
|
||||
|
||||
// If a comment is attached to the file(s) it is in the 'Text' field of the Slack messge event
|
||||
// and should be added as comment to only one of the files. We reset the 'Text' field to ensure
|
||||
// that the comment is not duplicated.
|
||||
comment := rmsg.Text
|
||||
rmsg.Text = ""
|
||||
helper.HandleDownloadData(b.Log, rmsg, file.Name, comment, file.URLPrivateDownload, data, b.General)
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleGetChannelMembers handles messages containing the GetChannelMembers event
|
||||
// Sends a message to the router containing *config.ChannelMembers
|
||||
func (b *Bslack) handleGetChannelMembers(rmsg *config.Message) bool {
|
||||
if rmsg.Event != config.EventGetChannelMembers {
|
||||
return false
|
||||
}
|
||||
|
||||
cMembers := b.channels.getChannelMembers(b.users)
|
||||
|
||||
extra := make(map[string][]interface{})
|
||||
extra[config.EventGetChannelMembers] = append(extra[config.EventGetChannelMembers], cMembers)
|
||||
msg := config.Message{
|
||||
Extra: extra,
|
||||
Event: config.EventGetChannelMembers,
|
||||
Account: b.Account,
|
||||
}
|
||||
|
||||
b.Log.Debugf("sending msg to remote %#v", msg)
|
||||
b.Remote <- msg
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// fileCached implements Matterbridge's caching logic for files
|
||||
// shared via Slack.
|
||||
//
|
||||
// We consider that a file was cached if its ID was added in the last minute or
|
||||
// it's name was registered in the last 10 seconds. This ensures that an
|
||||
// identically named file but with different content will be uploaded correctly
|
||||
// (the assumption is that such name collisions will not occur within the given
|
||||
// timeframes).
|
||||
func (b *Bslack) fileCached(file *slack.File) bool {
|
||||
if ts, ok := b.cache.Get("file" + file.ID); ok && time.Since(ts.(time.Time)) < time.Minute {
|
||||
return true
|
||||
} else if ts, ok = b.cache.Get("filename" + file.Name); ok && time.Since(ts.(time.Time)) < 10*time.Second {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
259
bridge/slack/helpers.go
Normal file
259
bridge/slack/helpers.go
Normal file
@@ -0,0 +1,259 @@
|
||||
package bslack
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/slack-go/slack"
|
||||
)
|
||||
|
||||
// populateReceivedMessage shapes the initial Matterbridge message that we will forward to the
|
||||
// router before we apply message-dependent modifications.
|
||||
func (b *Bslack) populateReceivedMessage(ev *slack.MessageEvent) (*config.Message, error) {
|
||||
// Use our own func because rtm.GetChannelInfo doesn't work for private channels.
|
||||
channel, err := b.channels.getChannelByID(ev.Channel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rmsg := &config.Message{
|
||||
Text: ev.Text,
|
||||
Channel: channel.Name,
|
||||
Account: b.Account,
|
||||
ID: ev.Timestamp,
|
||||
Extra: make(map[string][]interface{}),
|
||||
ParentID: ev.ThreadTimestamp,
|
||||
Protocol: b.Protocol,
|
||||
}
|
||||
if b.useChannelID {
|
||||
rmsg.Channel = "ID:" + channel.ID
|
||||
}
|
||||
|
||||
// Handle 'edit' messages.
|
||||
if ev.SubMessage != nil && !b.GetBool(editDisableConfig) {
|
||||
rmsg.ID = ev.SubMessage.Timestamp
|
||||
if ev.SubMessage.ThreadTimestamp != ev.SubMessage.Timestamp {
|
||||
b.Log.Debugf("SubMessage %#v", ev.SubMessage)
|
||||
rmsg.Text = ev.SubMessage.Text + b.GetString(editSuffixConfig)
|
||||
}
|
||||
}
|
||||
|
||||
// For edits, only submessage has thread ts.
|
||||
// Ensures edits to threaded messages maintain their prefix hint on the
|
||||
// unthreaded end.
|
||||
if ev.SubMessage != nil {
|
||||
rmsg.ParentID = ev.SubMessage.ThreadTimestamp
|
||||
}
|
||||
|
||||
if err = b.populateMessageWithUserInfo(ev, rmsg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rmsg, err
|
||||
}
|
||||
|
||||
func (b *Bslack) populateMessageWithUserInfo(ev *slack.MessageEvent, rmsg *config.Message) error {
|
||||
if ev.SubType == sMessageDeleted || ev.SubType == sFileComment {
|
||||
return nil
|
||||
}
|
||||
|
||||
// First, deal with bot-originating messages but only do so when not using webhooks: we
|
||||
// would not be able to distinguish which bot would be sending them.
|
||||
if err := b.populateMessageWithBotInfo(ev, rmsg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Second, deal with "real" users if we have the necessary information.
|
||||
var userID string
|
||||
switch {
|
||||
case ev.User != "":
|
||||
userID = ev.User
|
||||
case ev.SubMessage != nil && ev.SubMessage.User != "":
|
||||
userID = ev.SubMessage.User
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
user := b.users.getUser(userID)
|
||||
if user == nil {
|
||||
return fmt.Errorf("could not find information for user with id %s", ev.User)
|
||||
}
|
||||
|
||||
rmsg.UserID = user.ID
|
||||
rmsg.Username = user.Name
|
||||
if user.Profile.DisplayName != "" {
|
||||
rmsg.Username = user.Profile.DisplayName
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bslack) populateMessageWithBotInfo(ev *slack.MessageEvent, rmsg *config.Message) error {
|
||||
if ev.BotID == "" || b.GetString(outgoingWebhookConfig) != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var err error
|
||||
var bot *slack.Bot
|
||||
for {
|
||||
bot, err = b.rtm.GetBotInfo(ev.BotID)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
|
||||
if err = handleRateLimit(b.Log, err); err != nil {
|
||||
b.Log.Errorf("Could not retrieve bot information: %#v", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
b.Log.Debugf("Found bot %#v", bot)
|
||||
|
||||
if bot.Name != "" {
|
||||
rmsg.Username = bot.Name
|
||||
if ev.Username != "" {
|
||||
rmsg.Username = ev.Username
|
||||
}
|
||||
rmsg.UserID = bot.ID
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
mentionRE = regexp.MustCompile(`<@([a-zA-Z0-9]+)>`)
|
||||
channelRE = regexp.MustCompile(`<#[a-zA-Z0-9]+\|(.+?)>`)
|
||||
variableRE = regexp.MustCompile(`<!((?:subteam\^)?[a-zA-Z0-9]+)(?:\|@?(.+?))?>`)
|
||||
urlRE = regexp.MustCompile(`<(.*?)(\|.*?)?>`)
|
||||
codeFenceRE = regexp.MustCompile(`(?m)^` + "```" + `\w+$`)
|
||||
topicOrPurposeRE = regexp.MustCompile(`(?s)(@.+) (cleared|set)(?: the)? channel (topic|purpose)(?:: (.*))?`)
|
||||
)
|
||||
|
||||
func (b *Bslack) extractTopicOrPurpose(text string) (string, string) {
|
||||
r := topicOrPurposeRE.FindStringSubmatch(text)
|
||||
if len(r) == 5 {
|
||||
action, updateType, extracted := r[2], r[3], r[4]
|
||||
switch action {
|
||||
case "set":
|
||||
return updateType, extracted
|
||||
case "cleared":
|
||||
return updateType, ""
|
||||
}
|
||||
}
|
||||
b.Log.Warnf("Encountered channel topic or purpose change message with unexpected format: %s", text)
|
||||
return "unknown", ""
|
||||
}
|
||||
|
||||
// @see https://api.slack.com/docs/message-formatting#linking_to_channels_and_users
|
||||
func (b *Bslack) replaceMention(text string) string {
|
||||
replaceFunc := func(match string) string {
|
||||
userID := strings.Trim(match, "@<>")
|
||||
if username := b.users.getUsername(userID); userID != "" {
|
||||
return "@" + username
|
||||
}
|
||||
return match
|
||||
}
|
||||
return mentionRE.ReplaceAllStringFunc(text, replaceFunc)
|
||||
}
|
||||
|
||||
// @see https://api.slack.com/docs/message-formatting#linking_to_channels_and_users
|
||||
func (b *Bslack) replaceChannel(text string) string {
|
||||
for _, r := range channelRE.FindAllStringSubmatch(text, -1) {
|
||||
text = strings.Replace(text, r[0], "#"+r[1], 1)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// @see https://api.slack.com/docs/message-formatting#variables
|
||||
func (b *Bslack) replaceVariable(text string) string {
|
||||
for _, r := range variableRE.FindAllStringSubmatch(text, -1) {
|
||||
if r[2] != "" {
|
||||
text = strings.Replace(text, r[0], "@"+r[2], 1)
|
||||
} else {
|
||||
text = strings.Replace(text, r[0], "@"+r[1], 1)
|
||||
}
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// @see https://api.slack.com/docs/message-formatting#linking_to_urls
|
||||
func (b *Bslack) replaceURL(text string) string {
|
||||
for _, r := range urlRE.FindAllStringSubmatch(text, -1) {
|
||||
if len(strings.TrimSpace(r[2])) == 1 { // A display text separator was found, but the text was blank
|
||||
text = strings.Replace(text, r[0], "", 1)
|
||||
} else {
|
||||
text = strings.Replace(text, r[0], r[1], 1)
|
||||
}
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func (b *Bslack) replaceb0rkedMarkDown(text string) string {
|
||||
// taken from https://github.com/mattermost/mattermost-server/blob/master/app/slackimport.go
|
||||
//
|
||||
regexReplaceAllString := []struct {
|
||||
regex *regexp.Regexp
|
||||
rpl string
|
||||
}{
|
||||
// bold
|
||||
{
|
||||
regexp.MustCompile(`(^|[\s.;,])\*(\S[^*\n]+)\*`),
|
||||
"$1**$2**",
|
||||
},
|
||||
// strikethrough
|
||||
{
|
||||
regexp.MustCompile(`(^|[\s.;,])\~(\S[^~\n]+)\~`),
|
||||
"$1~~$2~~",
|
||||
},
|
||||
// single paragraph blockquote
|
||||
// Slack converts > character to >
|
||||
{
|
||||
regexp.MustCompile(`(?sm)^>`),
|
||||
">",
|
||||
},
|
||||
}
|
||||
for _, rule := range regexReplaceAllString {
|
||||
text = rule.regex.ReplaceAllString(text, rule.rpl)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func (b *Bslack) replaceCodeFence(text string) string {
|
||||
return codeFenceRE.ReplaceAllString(text, "```")
|
||||
}
|
||||
|
||||
// getUsersInConversation returns an array of userIDs that are members of channelID
|
||||
func (b *Bslack) getUsersInConversation(channelID string) ([]string, error) {
|
||||
channelMembers := []string{}
|
||||
for {
|
||||
queryParams := &slack.GetUsersInConversationParameters{
|
||||
ChannelID: channelID,
|
||||
}
|
||||
|
||||
members, nextCursor, err := b.sc.GetUsersInConversation(queryParams)
|
||||
if err != nil {
|
||||
if err = handleRateLimit(b.Log, err); err != nil {
|
||||
return channelMembers, fmt.Errorf("Could not retrieve users in channels: %#v", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
channelMembers = append(channelMembers, members...)
|
||||
|
||||
if nextCursor == "" {
|
||||
break
|
||||
}
|
||||
queryParams.Cursor = nextCursor
|
||||
}
|
||||
return channelMembers, nil
|
||||
}
|
||||
|
||||
func handleRateLimit(log *logrus.Entry, err error) error {
|
||||
rateLimit, ok := err.(*slack.RateLimitedError)
|
||||
if !ok {
|
||||
return err
|
||||
}
|
||||
log.Infof("Rate-limited by Slack. Sleeping for %v", rateLimit.RetryAfter)
|
||||
time.Sleep(rateLimit.RetryAfter)
|
||||
return nil
|
||||
}
|
||||
36
bridge/slack/helpers_test.go
Normal file
36
bridge/slack/helpers_test.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package bslack
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"testing"
|
||||
|
||||
"github.com/42wim/matterbridge/bridge"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestExtractTopicOrPurpose(t *testing.T) {
|
||||
testcases := map[string]struct {
|
||||
input string
|
||||
wantChangeType string
|
||||
wantOutput string
|
||||
}{
|
||||
"success - topic type": {"@someone set channel topic: foo bar", "topic", "foo bar"},
|
||||
"success - purpose type": {"@someone set channel purpose: foo bar", "purpose", "foo bar"},
|
||||
"success - one line": {"@someone set channel topic: foo bar", "topic", "foo bar"},
|
||||
"success - multi-line": {"@someone set channel topic: foo\nbar", "topic", "foo\nbar"},
|
||||
"success - cleared": {"@someone cleared channel topic", "topic", ""},
|
||||
"error - unhandled": {"some unmatched message", "unknown", ""},
|
||||
}
|
||||
|
||||
logger := logrus.New()
|
||||
logger.SetOutput(ioutil.Discard)
|
||||
cfg := &bridge.Config{Bridge: &bridge.Bridge{Log: logrus.NewEntry(logger)}}
|
||||
b := newBridge(cfg)
|
||||
for name, tc := range testcases {
|
||||
gotChangeType, gotOutput := b.extractTopicOrPurpose(tc.input)
|
||||
|
||||
assert.Equalf(t, tc.wantChangeType, gotChangeType, "This testcase failed: %s", name)
|
||||
assert.Equalf(t, tc.wantOutput, gotOutput, "This testcase failed: %s", name)
|
||||
}
|
||||
}
|
||||
80
bridge/slack/legacy.go
Normal file
80
bridge/slack/legacy.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package bslack
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/42wim/matterbridge/bridge"
|
||||
"github.com/42wim/matterbridge/matterhook"
|
||||
"github.com/slack-go/slack"
|
||||
)
|
||||
|
||||
type BLegacy struct {
|
||||
*Bslack
|
||||
}
|
||||
|
||||
func NewLegacy(cfg *bridge.Config) bridge.Bridger {
|
||||
b := &BLegacy{Bslack: newBridge(cfg)}
|
||||
b.legacy = true
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *BLegacy) Connect() error {
|
||||
b.RLock()
|
||||
defer b.RUnlock()
|
||||
if b.GetString(incomingWebhookConfig) != "" {
|
||||
switch {
|
||||
case b.GetString(outgoingWebhookConfig) != "":
|
||||
b.Log.Info("Connecting using webhookurl (sending) and webhookbindaddress (receiving)")
|
||||
b.mh = matterhook.New(b.GetString(outgoingWebhookConfig), matterhook.Config{
|
||||
InsecureSkipVerify: b.GetBool(skipTLSConfig),
|
||||
BindAddress: b.GetString(incomingWebhookConfig),
|
||||
})
|
||||
case b.GetString(tokenConfig) != "":
|
||||
b.Log.Info("Connecting using token (sending)")
|
||||
b.sc = slack.New(b.GetString(tokenConfig))
|
||||
b.rtm = b.sc.NewRTM()
|
||||
go b.rtm.ManageConnection()
|
||||
b.Log.Info("Connecting using webhookbindaddress (receiving)")
|
||||
b.mh = matterhook.New(b.GetString(outgoingWebhookConfig), matterhook.Config{
|
||||
InsecureSkipVerify: b.GetBool(skipTLSConfig),
|
||||
BindAddress: b.GetString(incomingWebhookConfig),
|
||||
})
|
||||
default:
|
||||
b.Log.Info("Connecting using webhookbindaddress (receiving)")
|
||||
b.mh = matterhook.New(b.GetString(outgoingWebhookConfig), matterhook.Config{
|
||||
InsecureSkipVerify: b.GetBool(skipTLSConfig),
|
||||
BindAddress: b.GetString(incomingWebhookConfig),
|
||||
})
|
||||
}
|
||||
go b.handleSlack()
|
||||
return nil
|
||||
}
|
||||
if b.GetString(outgoingWebhookConfig) != "" {
|
||||
b.Log.Info("Connecting using webhookurl (sending)")
|
||||
b.mh = matterhook.New(b.GetString(outgoingWebhookConfig), matterhook.Config{
|
||||
InsecureSkipVerify: b.GetBool(skipTLSConfig),
|
||||
DisableServer: true,
|
||||
})
|
||||
if b.GetString(tokenConfig) != "" {
|
||||
b.Log.Info("Connecting using token (receiving)")
|
||||
b.sc = slack.New(b.GetString(tokenConfig), slack.OptionDebug(b.GetBool("debug")))
|
||||
b.channels = newChannelManager(b.Log, b.sc)
|
||||
b.users = newUserManager(b.Log, b.sc)
|
||||
b.rtm = b.sc.NewRTM()
|
||||
go b.rtm.ManageConnection()
|
||||
go b.handleSlack()
|
||||
}
|
||||
} else if b.GetString(tokenConfig) != "" {
|
||||
b.Log.Info("Connecting using token (sending and receiving)")
|
||||
b.sc = slack.New(b.GetString(tokenConfig), slack.OptionDebug(b.GetBool("debug")))
|
||||
b.channels = newChannelManager(b.Log, b.sc)
|
||||
b.users = newUserManager(b.Log, b.sc)
|
||||
b.rtm = b.sc.NewRTM()
|
||||
go b.rtm.ManageConnection()
|
||||
go b.handleSlack()
|
||||
}
|
||||
if b.GetString(incomingWebhookConfig) == "" && b.GetString(outgoingWebhookConfig) == "" && b.GetString(tokenConfig) == "" {
|
||||
return errors.New("no connection method found. See that you have WebhookBindAddress, WebhookURL or Token configured")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -4,51 +4,91 @@ import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
"github.com/42wim/matterbridge/matterhook"
|
||||
log "github.com/Sirupsen/logrus"
|
||||
"github.com/nlopes/slack"
|
||||
"html"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/42wim/matterbridge/bridge"
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
"github.com/42wim/matterbridge/bridge/helper"
|
||||
"github.com/42wim/matterbridge/matterhook"
|
||||
lru "github.com/hashicorp/golang-lru"
|
||||
"github.com/rs/xid"
|
||||
"github.com/slack-go/slack"
|
||||
)
|
||||
|
||||
type MMMessage struct {
|
||||
Text string
|
||||
Channel string
|
||||
Username string
|
||||
UserID string
|
||||
Raw *slack.MessageEvent
|
||||
}
|
||||
|
||||
type Bslack struct {
|
||||
mh *matterhook.Client
|
||||
sc *slack.Client
|
||||
Config *config.Protocol
|
||||
rtm *slack.RTM
|
||||
Plus bool
|
||||
Remote chan config.Message
|
||||
Users []slack.User
|
||||
Account string
|
||||
si *slack.Info
|
||||
channels []slack.Channel
|
||||
sync.RWMutex
|
||||
*bridge.Config
|
||||
|
||||
mh *matterhook.Client
|
||||
sc *slack.Client
|
||||
rtm *slack.RTM
|
||||
si *slack.Info
|
||||
|
||||
cache *lru.Cache
|
||||
uuid string
|
||||
useChannelID bool
|
||||
|
||||
channels *channels
|
||||
users *users
|
||||
legacy bool
|
||||
}
|
||||
|
||||
var flog *log.Entry
|
||||
var protocol = "slack"
|
||||
const (
|
||||
sHello = "hello"
|
||||
sChannelJoin = "channel_join"
|
||||
sChannelLeave = "channel_leave"
|
||||
sChannelJoined = "channel_joined"
|
||||
sMemberJoined = "member_joined_channel"
|
||||
sMessageChanged = "message_changed"
|
||||
sMessageDeleted = "message_deleted"
|
||||
sSlackAttachment = "slack_attachment"
|
||||
sPinnedItem = "pinned_item"
|
||||
sUnpinnedItem = "unpinned_item"
|
||||
sChannelTopic = "channel_topic"
|
||||
sChannelPurpose = "channel_purpose"
|
||||
sFileComment = "file_comment"
|
||||
sMeMessage = "me_message"
|
||||
sUserTyping = "user_typing"
|
||||
sLatencyReport = "latency_report"
|
||||
sSystemUser = "system"
|
||||
sSlackBotUser = "slackbot"
|
||||
|
||||
func init() {
|
||||
flog = log.WithFields(log.Fields{"module": protocol})
|
||||
tokenConfig = "Token"
|
||||
incomingWebhookConfig = "WebhookBindAddress"
|
||||
outgoingWebhookConfig = "WebhookURL"
|
||||
skipTLSConfig = "SkipTLSVerify"
|
||||
useNickPrefixConfig = "PrefixMessagesWithNick"
|
||||
editDisableConfig = "EditDisable"
|
||||
editSuffixConfig = "EditSuffix"
|
||||
iconURLConfig = "iconurl"
|
||||
noSendJoinConfig = "nosendjoinpart"
|
||||
messageLength = 3000
|
||||
)
|
||||
|
||||
func New(cfg *bridge.Config) bridge.Bridger {
|
||||
// Print a deprecation warning for legacy non-bot tokens (#527).
|
||||
token := cfg.GetString(tokenConfig)
|
||||
if token != "" && !strings.HasPrefix(token, "xoxb") {
|
||||
cfg.Log.Warn("Non-bot token detected. It is STRONGLY recommended to use a proper bot-token instead.")
|
||||
cfg.Log.Warn("Legacy tokens may be deprecated by Slack at short notice. See the Matterbridge GitHub wiki for a migration guide.")
|
||||
cfg.Log.Warn("See https://github.com/42wim/matterbridge/wiki/Slack-bot-setup")
|
||||
return NewLegacy(cfg)
|
||||
}
|
||||
return newBridge(cfg)
|
||||
}
|
||||
|
||||
func New(cfg config.Protocol, account string, c chan config.Message) *Bslack {
|
||||
b := &Bslack{}
|
||||
b.Config = &cfg
|
||||
b.Remote = c
|
||||
b.Account = account
|
||||
func newBridge(cfg *bridge.Config) *Bslack {
|
||||
newCache, err := lru.New(5000)
|
||||
if err != nil {
|
||||
cfg.Log.Fatalf("Could not create LRU cache for Slack bridge: %v", err)
|
||||
}
|
||||
b := &Bslack{
|
||||
Config: cfg,
|
||||
uuid: xid.New().String(),
|
||||
cache: newCache,
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
@@ -57,406 +97,459 @@ func (b *Bslack) Command(cmd string) string {
|
||||
}
|
||||
|
||||
func (b *Bslack) Connect() error {
|
||||
if b.Config.WebhookBindAddress != "" {
|
||||
if b.Config.WebhookURL != "" {
|
||||
flog.Info("Connecting using webhookurl (sending) and webhookbindaddress (receiving)")
|
||||
b.mh = matterhook.New(b.Config.WebhookURL,
|
||||
matterhook.Config{InsecureSkipVerify: b.Config.SkipTLSVerify,
|
||||
BindAddress: b.Config.WebhookBindAddress})
|
||||
} else if b.Config.Token != "" {
|
||||
flog.Info("Connecting using token (sending)")
|
||||
b.sc = slack.New(b.Config.Token)
|
||||
b.rtm = b.sc.NewRTM()
|
||||
go b.rtm.ManageConnection()
|
||||
flog.Info("Connecting using webhookbindaddress (receiving)")
|
||||
b.mh = matterhook.New(b.Config.WebhookURL,
|
||||
matterhook.Config{InsecureSkipVerify: b.Config.SkipTLSVerify,
|
||||
BindAddress: b.Config.WebhookBindAddress})
|
||||
} else {
|
||||
flog.Info("Connecting using webhookbindaddress (receiving)")
|
||||
b.mh = matterhook.New(b.Config.WebhookURL,
|
||||
matterhook.Config{InsecureSkipVerify: b.Config.SkipTLSVerify,
|
||||
BindAddress: b.Config.WebhookBindAddress})
|
||||
}
|
||||
go b.handleSlack()
|
||||
return nil
|
||||
b.RLock()
|
||||
defer b.RUnlock()
|
||||
|
||||
if b.GetString(incomingWebhookConfig) == "" && b.GetString(outgoingWebhookConfig) == "" && b.GetString(tokenConfig) == "" {
|
||||
return errors.New("no connection method found: WebhookBindAddress, WebhookURL or Token need to be configured")
|
||||
}
|
||||
if b.Config.WebhookURL != "" {
|
||||
flog.Info("Connecting using webhookurl (sending)")
|
||||
b.mh = matterhook.New(b.Config.WebhookURL,
|
||||
matterhook.Config{InsecureSkipVerify: b.Config.SkipTLSVerify,
|
||||
DisableServer: true})
|
||||
if b.Config.Token != "" {
|
||||
flog.Info("Connecting using token (receiving)")
|
||||
b.sc = slack.New(b.Config.Token)
|
||||
b.rtm = b.sc.NewRTM()
|
||||
go b.rtm.ManageConnection()
|
||||
go b.handleSlack()
|
||||
}
|
||||
} else if b.Config.Token != "" {
|
||||
flog.Info("Connecting using token (sending and receiving)")
|
||||
b.sc = slack.New(b.Config.Token)
|
||||
|
||||
// If we have a token we use the Slack websocket-based RTM for both sending and receiving.
|
||||
if token := b.GetString(tokenConfig); token != "" {
|
||||
b.Log.Info("Connecting using token")
|
||||
|
||||
b.sc = slack.New(token, slack.OptionDebug(b.GetBool("Debug")))
|
||||
|
||||
b.channels = newChannelManager(b.Log, b.sc)
|
||||
b.users = newUserManager(b.Log, b.sc)
|
||||
|
||||
b.rtm = b.sc.NewRTM()
|
||||
go b.rtm.ManageConnection()
|
||||
go b.handleSlack()
|
||||
return nil
|
||||
}
|
||||
if b.Config.WebhookBindAddress == "" && b.Config.WebhookURL == "" && b.Config.Token == "" {
|
||||
return errors.New("No connection method found. See that you have WebhookBindAddress, WebhookURL or Token configured.")
|
||||
|
||||
// In absence of a token we fall back to incoming and outgoing Webhooks.
|
||||
b.mh = matterhook.New(
|
||||
"",
|
||||
matterhook.Config{
|
||||
InsecureSkipVerify: b.GetBool("SkipTLSVerify"),
|
||||
DisableServer: true,
|
||||
},
|
||||
)
|
||||
if b.GetString(outgoingWebhookConfig) != "" {
|
||||
b.Log.Info("Using specified webhook for outgoing messages.")
|
||||
b.mh.Url = b.GetString(outgoingWebhookConfig)
|
||||
}
|
||||
if b.GetString(incomingWebhookConfig) != "" {
|
||||
b.Log.Info("Setting up local webhook for incoming messages.")
|
||||
b.mh.BindAddress = b.GetString(incomingWebhookConfig)
|
||||
b.mh.DisableServer = false
|
||||
go b.handleSlack()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bslack) Disconnect() error {
|
||||
return nil
|
||||
|
||||
return b.rtm.Disconnect()
|
||||
}
|
||||
|
||||
// JoinChannel only acts as a verification method that checks whether Matterbridge's
|
||||
// Slack integration is already member of the channel. This is because Slack does not
|
||||
// allow apps or bots to join channels themselves and they need to be invited
|
||||
// manually by a user.
|
||||
func (b *Bslack) JoinChannel(channel config.ChannelInfo) error {
|
||||
// we can only join channels using the API
|
||||
if b.Config.WebhookURL == "" && b.Config.WebhookBindAddress == "" {
|
||||
if strings.HasPrefix(b.Config.Token, "xoxb") {
|
||||
// TODO check if bot has already joined channel
|
||||
return nil
|
||||
}
|
||||
// We can only join a channel through the Slack API.
|
||||
if b.sc == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// try to join a channel when in legacy
|
||||
if b.legacy {
|
||||
_, err := b.sc.JoinChannel(channel.Name)
|
||||
if err != nil {
|
||||
if err.Error() != "name_taken" {
|
||||
switch err.Error() {
|
||||
case "name_taken", "restricted_action":
|
||||
case "default":
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
b.channels.populateChannels(false)
|
||||
|
||||
channelInfo, err := b.channels.getChannel(channel.Name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not join channel: %#v", err)
|
||||
}
|
||||
|
||||
if strings.HasPrefix(channel.Name, "ID:") {
|
||||
b.useChannelID = true
|
||||
channel.Name = channelInfo.Name
|
||||
}
|
||||
|
||||
// we can't join a channel unless we are using legacy tokens #651
|
||||
if !channelInfo.IsMember && !b.legacy {
|
||||
return fmt.Errorf("slack integration that matterbridge is using is not member of channel '%s', please add it manually", channelInfo.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bslack) Reload(cfg *bridge.Config) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (b *Bslack) Send(msg config.Message) (string, error) {
|
||||
flog.Debugf("Receiving %#v", msg)
|
||||
if msg.Event == config.EVENT_USER_ACTION {
|
||||
// Too noisy to log like other events
|
||||
if msg.Event != config.EventUserTyping {
|
||||
b.Log.Debugf("=> Receiving %#v", msg)
|
||||
}
|
||||
|
||||
msg.Text = helper.ClipMessage(msg.Text, messageLength)
|
||||
msg.Text = b.replaceCodeFence(msg.Text)
|
||||
|
||||
// Make a action /me of the message
|
||||
if msg.Event == config.EventUserAction {
|
||||
msg.Text = "_" + msg.Text + "_"
|
||||
}
|
||||
nick := msg.Username
|
||||
message := msg.Text
|
||||
channel := msg.Channel
|
||||
if b.Config.PrefixMessagesWithNick {
|
||||
message = nick + " " + message
|
||||
}
|
||||
if b.Config.WebhookURL != "" {
|
||||
matterMessage := matterhook.OMessage{IconURL: b.Config.IconURL}
|
||||
matterMessage.Channel = channel
|
||||
matterMessage.UserName = nick
|
||||
matterMessage.Type = ""
|
||||
matterMessage.Text = message
|
||||
err := b.mh.Send(matterMessage)
|
||||
if err != nil {
|
||||
flog.Info(err)
|
||||
return "", err
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
schannel, err := b.getChannelByName(channel)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
np := slack.NewPostMessageParameters()
|
||||
if b.Config.PrefixMessagesWithNick {
|
||||
np.AsUser = true
|
||||
}
|
||||
np.Username = nick
|
||||
np.IconURL = config.GetIconURL(&msg, b.Config)
|
||||
if msg.Avatar != "" {
|
||||
np.IconURL = msg.Avatar
|
||||
}
|
||||
np.Attachments = append(np.Attachments, slack.Attachment{CallbackID: "matterbridge"})
|
||||
np.Attachments = append(np.Attachments, b.createAttach(msg.Extra)...)
|
||||
|
||||
// replace mentions
|
||||
np.LinkNames = 1
|
||||
|
||||
if msg.Event == config.EVENT_MSG_DELETE {
|
||||
// some protocols echo deletes, but with empty ID
|
||||
if msg.ID == "" {
|
||||
return "", nil
|
||||
}
|
||||
// we get a "slack <ID>", split it
|
||||
ts := strings.Fields(msg.ID)
|
||||
b.sc.DeleteMessage(schannel.ID, ts[1])
|
||||
return "", nil
|
||||
// Use webhook to send the message
|
||||
if b.GetString(outgoingWebhookConfig) != "" && b.GetString(tokenConfig) == "" {
|
||||
return "", b.sendWebhook(msg)
|
||||
}
|
||||
// if we have no ID it means we're creating a new message, not updating an existing one
|
||||
if msg.ID != "" {
|
||||
ts := strings.Fields(msg.ID)
|
||||
b.sc.UpdateMessage(schannel.ID, ts[1], message)
|
||||
return "", nil
|
||||
}
|
||||
_, id, err := b.sc.PostMessage(schannel.ID, message, np)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "slack " + id, nil
|
||||
return b.sendRTM(msg)
|
||||
}
|
||||
|
||||
func (b *Bslack) getAvatar(user string) string {
|
||||
var avatar string
|
||||
if b.Users != nil {
|
||||
for _, u := range b.Users {
|
||||
if user == u.Name {
|
||||
return u.Profile.Image48
|
||||
// sendWebhook uses the configured WebhookURL to send the message
|
||||
func (b *Bslack) sendWebhook(msg config.Message) error {
|
||||
// Skip events.
|
||||
if msg.Event != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if b.GetBool(useNickPrefixConfig) {
|
||||
msg.Text = msg.Username + msg.Text
|
||||
}
|
||||
|
||||
if msg.Extra != nil {
|
||||
// This sends a message only if we received a config.EVENT_FILE_FAILURE_SIZE.
|
||||
for _, rmsg := range helper.HandleExtra(&msg, b.General) {
|
||||
rmsg := rmsg // scopelint
|
||||
iconURL := config.GetIconURL(&rmsg, b.GetString(iconURLConfig))
|
||||
matterMessage := matterhook.OMessage{
|
||||
IconURL: iconURL,
|
||||
Channel: msg.Channel,
|
||||
UserName: rmsg.Username,
|
||||
Text: rmsg.Text,
|
||||
}
|
||||
if err := b.mh.Send(matterMessage); err != nil {
|
||||
b.Log.Errorf("Failed to send message: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return avatar
|
||||
}
|
||||
|
||||
func (b *Bslack) getChannelByName(name string) (*slack.Channel, error) {
|
||||
if b.channels == nil {
|
||||
return nil, fmt.Errorf("%s: channel %s not found (no channels found)", b.Account, name)
|
||||
}
|
||||
for _, channel := range b.channels {
|
||||
if channel.Name == name {
|
||||
return &channel, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("%s: channel %s not found", b.Account, name)
|
||||
}
|
||||
|
||||
func (b *Bslack) getChannelByID(ID string) (*slack.Channel, error) {
|
||||
if b.channels == nil {
|
||||
return nil, fmt.Errorf("%s: channel %s not found (no channels found)", b.Account, ID)
|
||||
}
|
||||
for _, channel := range b.channels {
|
||||
if channel.ID == ID {
|
||||
return &channel, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("%s: channel %s not found", b.Account, ID)
|
||||
}
|
||||
|
||||
func (b *Bslack) handleSlack() {
|
||||
mchan := make(chan *MMMessage)
|
||||
if b.Config.WebhookBindAddress != "" {
|
||||
flog.Debugf("Choosing webhooks based receiving")
|
||||
go b.handleMatterHook(mchan)
|
||||
} else {
|
||||
flog.Debugf("Choosing token based receiving")
|
||||
go b.handleSlackClient(mchan)
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
flog.Debug("Start listening for Slack messages")
|
||||
for message := range mchan {
|
||||
// do not send messages from ourself
|
||||
if b.Config.WebhookURL == "" && b.Config.WebhookBindAddress == "" && message.Username == b.si.User.Name {
|
||||
continue
|
||||
}
|
||||
if (message.Text == "" || message.Username == "") && message.Raw.SubType != "message_deleted" {
|
||||
continue
|
||||
}
|
||||
text := message.Text
|
||||
text = b.replaceURL(text)
|
||||
text = html.UnescapeString(text)
|
||||
flog.Debugf("Sending message from %s on %s to gateway", message.Username, b.Account)
|
||||
msg := config.Message{Text: text, Username: message.Username, Channel: message.Channel, Account: b.Account, Avatar: b.getAvatar(message.Username), UserID: message.UserID, ID: "slack " + message.Raw.Timestamp, Extra: make(map[string][]interface{})}
|
||||
if message.Raw.SubType == "me_message" {
|
||||
msg.Event = config.EVENT_USER_ACTION
|
||||
}
|
||||
if message.Raw.SubType == "channel_leave" || message.Raw.SubType == "channel_join" {
|
||||
msg.Username = "system"
|
||||
msg.Event = config.EVENT_JOIN_LEAVE
|
||||
}
|
||||
// edited messages have a submessage, use this timestamp
|
||||
if message.Raw.SubMessage != nil {
|
||||
msg.ID = "slack " + message.Raw.SubMessage.Timestamp
|
||||
}
|
||||
if message.Raw.SubType == "message_deleted" {
|
||||
msg.Text = config.EVENT_MSG_DELETE
|
||||
msg.Event = config.EVENT_MSG_DELETE
|
||||
msg.ID = "slack " + message.Raw.DeletedTimestamp
|
||||
}
|
||||
|
||||
// if we have a file attached, download it (in memory) and put a pointer to it in msg.Extra
|
||||
if message.Raw.File != nil {
|
||||
// limit to 1MB for now
|
||||
if message.Raw.File.Size <= 1000000 {
|
||||
data, err := b.downloadFile(message.Raw.File.URLPrivateDownload)
|
||||
if err != nil {
|
||||
flog.Errorf("download %s failed %#v", message.Raw.File.URLPrivateDownload, err)
|
||||
} else {
|
||||
msg.Extra["file"] = append(msg.Extra["file"], config.FileInfo{Name: message.Raw.File.Name, Data: data})
|
||||
}
|
||||
}
|
||||
}
|
||||
flog.Debugf("Message is %#v", msg)
|
||||
b.Remote <- msg
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bslack) handleSlackClient(mchan chan *MMMessage) {
|
||||
for msg := range b.rtm.IncomingEvents {
|
||||
switch ev := msg.Data.(type) {
|
||||
case *slack.MessageEvent:
|
||||
flog.Debugf("Receiving from slackclient %#v", ev)
|
||||
if len(ev.Attachments) > 0 {
|
||||
// skip messages we made ourselves
|
||||
if ev.Attachments[0].CallbackID == "matterbridge" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if !b.Config.EditDisable && ev.SubMessage != nil && ev.SubMessage.ThreadTimestamp != ev.SubMessage.Timestamp {
|
||||
flog.Debugf("SubMessage %#v", ev.SubMessage)
|
||||
ev.User = ev.SubMessage.User
|
||||
ev.Text = ev.SubMessage.Text + b.Config.EditSuffix
|
||||
|
||||
// it seems ev.SubMessage.Edited == nil when slack unfurls
|
||||
// do not forward these messages #266
|
||||
if ev.SubMessage.Edited == nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
// use our own func because rtm.GetChannelInfo doesn't work for private channels
|
||||
channel, err := b.getChannelByID(ev.Channel)
|
||||
if err != nil {
|
||||
// Webhook doesn't support file uploads, so we add the URL manually.
|
||||
for _, f := range msg.Extra["file"] {
|
||||
fi, ok := f.(config.FileInfo)
|
||||
if !ok {
|
||||
b.Log.Errorf("Received a file with unexpected content: %#v", f)
|
||||
continue
|
||||
}
|
||||
m := &MMMessage{}
|
||||
if ev.BotID == "" && ev.SubType != "message_deleted" {
|
||||
user, err := b.rtm.GetUserInfo(ev.User)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
m.UserID = user.ID
|
||||
m.Username = user.Name
|
||||
if fi.URL != "" {
|
||||
msg.Text += " " + fi.URL
|
||||
}
|
||||
m.Channel = channel.Name
|
||||
m.Text = ev.Text
|
||||
if m.Text == "" {
|
||||
for _, attach := range ev.Attachments {
|
||||
if attach.Text != "" {
|
||||
m.Text = attach.Text
|
||||
} else {
|
||||
m.Text = attach.Fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we have native slack_attachments add them.
|
||||
var attachs []slack.Attachment
|
||||
for _, attach := range msg.Extra[sSlackAttachment] {
|
||||
attachs = append(attachs, attach.([]slack.Attachment)...)
|
||||
}
|
||||
|
||||
iconURL := config.GetIconURL(&msg, b.GetString(iconURLConfig))
|
||||
matterMessage := matterhook.OMessage{
|
||||
IconURL: iconURL,
|
||||
Attachments: attachs,
|
||||
Channel: msg.Channel,
|
||||
UserName: msg.Username,
|
||||
Text: msg.Text,
|
||||
}
|
||||
if msg.Avatar != "" {
|
||||
matterMessage.IconURL = msg.Avatar
|
||||
}
|
||||
if err := b.mh.Send(matterMessage); err != nil {
|
||||
b.Log.Errorf("Failed to send message via webhook: %#v", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bslack) sendRTM(msg config.Message) (string, error) {
|
||||
// Handle channelmember messages.
|
||||
if handled := b.handleGetChannelMembers(&msg); handled {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
channelInfo, err := b.channels.getChannel(msg.Channel)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not send message: %v", err)
|
||||
}
|
||||
if msg.Event == config.EventUserTyping {
|
||||
if b.GetBool("ShowUserTyping") {
|
||||
b.rtm.SendMessage(b.rtm.NewTypingMessage(channelInfo.ID))
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
var handled bool
|
||||
|
||||
// Handle topic/purpose updates.
|
||||
if handled, err = b.handleTopicOrPurpose(&msg, channelInfo); handled {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Handle prefix hint for unthreaded messages.
|
||||
if msg.ParentNotFound() {
|
||||
msg.ParentID = ""
|
||||
msg.Text = fmt.Sprintf("[thread]: %s", msg.Text)
|
||||
}
|
||||
|
||||
// Handle message deletions.
|
||||
if handled, err = b.deleteMessage(&msg, channelInfo); handled {
|
||||
return msg.ID, err
|
||||
}
|
||||
|
||||
// Prepend nickname if configured.
|
||||
if b.GetBool(useNickPrefixConfig) {
|
||||
msg.Text = msg.Username + msg.Text
|
||||
}
|
||||
|
||||
// Handle message edits.
|
||||
if handled, err = b.editMessage(&msg, channelInfo); handled {
|
||||
return msg.ID, err
|
||||
}
|
||||
|
||||
// Upload a file if it exists.
|
||||
if msg.Extra != nil {
|
||||
extraMsgs := helper.HandleExtra(&msg, b.General)
|
||||
for i := range extraMsgs {
|
||||
rmsg := &extraMsgs[i]
|
||||
rmsg.Text = rmsg.Username + rmsg.Text
|
||||
_, err = b.postMessage(rmsg, channelInfo)
|
||||
if err != nil {
|
||||
b.Log.Error(err)
|
||||
}
|
||||
m.Raw = ev
|
||||
m.Text = b.replaceMention(m.Text)
|
||||
// when using webhookURL we can't check if it's our webhook or not for now
|
||||
if ev.BotID != "" && b.Config.WebhookURL == "" {
|
||||
bot, err := b.rtm.GetBotInfo(ev.BotID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if bot.Name != "" {
|
||||
m.Username = bot.Name
|
||||
if ev.Username != "" {
|
||||
m.Username = ev.Username
|
||||
}
|
||||
m.UserID = bot.ID
|
||||
}
|
||||
}
|
||||
mchan <- m
|
||||
case *slack.OutgoingErrorEvent:
|
||||
flog.Debugf("%#v", ev.Error())
|
||||
case *slack.ChannelJoinedEvent:
|
||||
b.Users, _ = b.sc.GetUsers()
|
||||
case *slack.ConnectedEvent:
|
||||
b.channels = ev.Info.Channels
|
||||
b.si = ev.Info
|
||||
b.Users, _ = b.sc.GetUsers()
|
||||
// add private channels
|
||||
groups, _ := b.sc.GetGroups(true)
|
||||
for _, g := range groups {
|
||||
channel := new(slack.Channel)
|
||||
channel.ID = g.ID
|
||||
channel.Name = g.Name
|
||||
b.channels = append(b.channels, *channel)
|
||||
}
|
||||
case *slack.InvalidAuthEvent:
|
||||
flog.Fatalf("Invalid Token %#v", ev)
|
||||
default:
|
||||
}
|
||||
// Upload files if necessary (from Slack, Telegram or Mattermost).
|
||||
b.uploadFile(&msg, channelInfo.ID)
|
||||
}
|
||||
|
||||
// Post message.
|
||||
return b.postMessage(&msg, channelInfo)
|
||||
}
|
||||
|
||||
func (b *Bslack) updateTopicOrPurpose(msg *config.Message, channelInfo *slack.Channel) error {
|
||||
var updateFunc func(channelID string, value string) (*slack.Channel, error)
|
||||
|
||||
incomingChangeType, text := b.extractTopicOrPurpose(msg.Text)
|
||||
switch incomingChangeType {
|
||||
case "topic":
|
||||
updateFunc = b.rtm.SetTopicOfConversation
|
||||
case "purpose":
|
||||
updateFunc = b.rtm.SetPurposeOfConversation
|
||||
default:
|
||||
b.Log.Errorf("Unhandled type received from extractTopicOrPurpose: %s", incomingChangeType)
|
||||
return nil
|
||||
}
|
||||
for {
|
||||
_, err := updateFunc(channelInfo.ID, text)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if err = handleRateLimit(b.Log, err); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bslack) handleMatterHook(mchan chan *MMMessage) {
|
||||
// handles updating topic/purpose and determining whether to further propagate update messages.
|
||||
func (b *Bslack) handleTopicOrPurpose(msg *config.Message, channelInfo *slack.Channel) (bool, error) {
|
||||
if msg.Event != config.EventTopicChange {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if b.GetBool("SyncTopic") {
|
||||
return true, b.updateTopicOrPurpose(msg, channelInfo)
|
||||
}
|
||||
|
||||
// Pass along to normal message handlers.
|
||||
if b.GetBool("ShowTopicChange") {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Swallow message as handled no-op.
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (b *Bslack) deleteMessage(msg *config.Message, channelInfo *slack.Channel) (bool, error) {
|
||||
if msg.Event != config.EventMsgDelete {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Some protocols echo deletes, but with an empty ID.
|
||||
if msg.ID == "" {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
for {
|
||||
message := b.mh.Receive()
|
||||
flog.Debugf("receiving from matterhook (slack) %#v", message)
|
||||
m := &MMMessage{}
|
||||
m.Username = message.UserName
|
||||
m.Text = message.Text
|
||||
m.Text = b.replaceMention(m.Text)
|
||||
m.Channel = message.ChannelName
|
||||
if m.Username == "slackbot" {
|
||||
_, _, err := b.rtm.DeleteMessage(channelInfo.ID, msg.ID)
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if err = handleRateLimit(b.Log, err); err != nil {
|
||||
b.Log.Errorf("Failed to delete user message from Slack: %#v", err)
|
||||
return true, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bslack) editMessage(msg *config.Message, channelInfo *slack.Channel) (bool, error) {
|
||||
if msg.ID == "" {
|
||||
return false, nil
|
||||
}
|
||||
messageOptions := b.prepareMessageOptions(msg)
|
||||
for {
|
||||
_, _, _, err := b.rtm.UpdateMessage(channelInfo.ID, msg.ID, messageOptions...)
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if err = handleRateLimit(b.Log, err); err != nil {
|
||||
b.Log.Errorf("Failed to edit user message on Slack: %#v", err)
|
||||
return true, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bslack) postMessage(msg *config.Message, channelInfo *slack.Channel) (string, error) {
|
||||
// don't post empty messages
|
||||
if msg.Text == "" {
|
||||
return "", nil
|
||||
}
|
||||
messageOptions := b.prepareMessageOptions(msg)
|
||||
for {
|
||||
_, id, err := b.rtm.PostMessage(channelInfo.ID, messageOptions...)
|
||||
if err == nil {
|
||||
return id, nil
|
||||
}
|
||||
|
||||
if err = handleRateLimit(b.Log, err); err != nil {
|
||||
b.Log.Errorf("Failed to sent user message to Slack: %#v", err)
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// uploadFile handles native upload of files
|
||||
func (b *Bslack) uploadFile(msg *config.Message, channelID string) {
|
||||
for _, f := range msg.Extra["file"] {
|
||||
fi, ok := f.(config.FileInfo)
|
||||
if !ok {
|
||||
b.Log.Errorf("Received a file with unexpected content: %#v", f)
|
||||
continue
|
||||
}
|
||||
mchan <- m
|
||||
if msg.Text == fi.Comment {
|
||||
msg.Text = ""
|
||||
}
|
||||
// Because the result of the UploadFile is slower than the MessageEvent from slack
|
||||
// we can't match on the file ID yet, so we have to match on the filename too.
|
||||
ts := time.Now()
|
||||
b.Log.Debugf("Adding file %s to cache at %s with timestamp", fi.Name, ts.String())
|
||||
b.cache.Add("filename"+fi.Name, ts)
|
||||
initialComment := fmt.Sprintf("File from %s", msg.Username)
|
||||
if fi.Comment != "" {
|
||||
initialComment += fmt.Sprintf("with comment: %s", fi.Comment)
|
||||
}
|
||||
res, err := b.sc.UploadFile(slack.FileUploadParameters{
|
||||
Reader: bytes.NewReader(*fi.Data),
|
||||
Filename: fi.Name,
|
||||
Channels: []string{channelID},
|
||||
InitialComment: initialComment,
|
||||
ThreadTimestamp: msg.ParentID,
|
||||
})
|
||||
if err != nil {
|
||||
b.Log.Errorf("uploadfile %#v", err)
|
||||
return
|
||||
}
|
||||
if res.ID != "" {
|
||||
b.Log.Debugf("Adding file ID %s to cache with timestamp %s", res.ID, ts.String())
|
||||
b.cache.Add("file"+res.ID, ts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bslack) userName(id string) string {
|
||||
for _, u := range b.Users {
|
||||
if u.ID == id {
|
||||
return u.Name
|
||||
func (b *Bslack) prepareMessageOptions(msg *config.Message) []slack.MsgOption {
|
||||
params := slack.NewPostMessageParameters()
|
||||
if b.GetBool(useNickPrefixConfig) {
|
||||
params.AsUser = true
|
||||
}
|
||||
params.Username = msg.Username
|
||||
params.LinkNames = 1 // replace mentions
|
||||
params.IconURL = config.GetIconURL(msg, b.GetString(iconURLConfig))
|
||||
params.ThreadTimestamp = msg.ParentID
|
||||
if msg.Avatar != "" {
|
||||
params.IconURL = msg.Avatar
|
||||
}
|
||||
|
||||
var attachments []slack.Attachment
|
||||
// add file attachments
|
||||
attachments = append(attachments, b.createAttach(msg.Extra)...)
|
||||
// add slack attachments (from another slack bridge)
|
||||
if msg.Extra != nil {
|
||||
for _, attach := range msg.Extra[sSlackAttachment] {
|
||||
attachments = append(attachments, attach.([]slack.Attachment)...)
|
||||
}
|
||||
}
|
||||
|
||||
var opts []slack.MsgOption
|
||||
opts = append(opts,
|
||||
// provide regular text field (fallback used in Slack notifications, etc.)
|
||||
slack.MsgOptionText(msg.Text, false),
|
||||
|
||||
// add a callback ID so we can see we created it
|
||||
slack.MsgOptionBlocks(slack.NewSectionBlock(
|
||||
slack.NewTextBlockObject(slack.MarkdownType, msg.Text, false, false),
|
||||
nil, nil,
|
||||
slack.SectionBlockOptionBlockID("matterbridge_"+b.uuid),
|
||||
)),
|
||||
|
||||
slack.MsgOptionEnableLinkUnfurl(),
|
||||
)
|
||||
opts = append(opts, slack.MsgOptionAttachments(attachments...))
|
||||
opts = append(opts, slack.MsgOptionPostMessageParameters(params))
|
||||
return opts
|
||||
}
|
||||
|
||||
func (b *Bslack) createAttach(extra map[string][]interface{}) []slack.Attachment {
|
||||
var attachements []slack.Attachment
|
||||
for _, v := range extra["attachments"] {
|
||||
entry := v.(map[string]interface{})
|
||||
s := slack.Attachment{
|
||||
Fallback: extractStringField(entry, "fallback"),
|
||||
Color: extractStringField(entry, "color"),
|
||||
Pretext: extractStringField(entry, "pretext"),
|
||||
AuthorName: extractStringField(entry, "author_name"),
|
||||
AuthorLink: extractStringField(entry, "author_link"),
|
||||
AuthorIcon: extractStringField(entry, "author_icon"),
|
||||
Title: extractStringField(entry, "title"),
|
||||
TitleLink: extractStringField(entry, "title_link"),
|
||||
Text: extractStringField(entry, "text"),
|
||||
ImageURL: extractStringField(entry, "image_url"),
|
||||
ThumbURL: extractStringField(entry, "thumb_url"),
|
||||
Footer: extractStringField(entry, "footer"),
|
||||
FooterIcon: extractStringField(entry, "footer_icon"),
|
||||
}
|
||||
attachements = append(attachements, s)
|
||||
}
|
||||
return attachements
|
||||
}
|
||||
|
||||
func extractStringField(data map[string]interface{}, field string) string {
|
||||
if rawValue, found := data[field]; found {
|
||||
if value, ok := rawValue.(string); ok {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (b *Bslack) replaceMention(text string) string {
|
||||
results := regexp.MustCompile(`<@([a-zA-z0-9]+)>`).FindAllStringSubmatch(text, -1)
|
||||
for _, r := range results {
|
||||
text = strings.Replace(text, "<@"+r[1]+">", "@"+b.userName(r[1]), -1)
|
||||
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func (b *Bslack) replaceURL(text string) string {
|
||||
results := regexp.MustCompile(`<(.*?)\|.*?>`).FindAllStringSubmatch(text, -1)
|
||||
for _, r := range results {
|
||||
text = strings.Replace(text, r[0], r[1], -1)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func (b *Bslack) createAttach(extra map[string][]interface{}) []slack.Attachment {
|
||||
var attachs []slack.Attachment
|
||||
for _, v := range extra["attachments"] {
|
||||
entry := v.(map[string]interface{})
|
||||
s := slack.Attachment{}
|
||||
s.Fallback = entry["fallback"].(string)
|
||||
s.Color = entry["color"].(string)
|
||||
s.Pretext = entry["pretext"].(string)
|
||||
s.AuthorName = entry["author_name"].(string)
|
||||
s.AuthorLink = entry["author_link"].(string)
|
||||
s.AuthorIcon = entry["author_icon"].(string)
|
||||
s.Title = entry["title"].(string)
|
||||
s.TitleLink = entry["title_link"].(string)
|
||||
s.Text = entry["text"].(string)
|
||||
s.ImageURL = entry["image_url"].(string)
|
||||
s.ThumbURL = entry["thumb_url"].(string)
|
||||
s.Footer = entry["footer"].(string)
|
||||
s.FooterIcon = entry["footer_icon"].(string)
|
||||
attachs = append(attachs, s)
|
||||
}
|
||||
return attachs
|
||||
}
|
||||
|
||||
func (b *Bslack) downloadFile(url string) (*[]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
client := &http.Client{
|
||||
Timeout: time.Second * 5,
|
||||
}
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Add("Authorization", "Bearer "+b.Config.Token)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
resp.Body.Close()
|
||||
return nil, err
|
||||
}
|
||||
io.Copy(&buf, resp.Body)
|
||||
data := buf.Bytes()
|
||||
resp.Body.Close()
|
||||
return &data, nil
|
||||
}
|
||||
|
||||
336
bridge/slack/users_channels.go
Normal file
336
bridge/slack/users_channels.go
Normal file
@@ -0,0 +1,336 @@
|
||||
package bslack
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/slack-go/slack"
|
||||
)
|
||||
|
||||
const minimumRefreshInterval = 10 * time.Second
|
||||
|
||||
type users struct {
|
||||
log *logrus.Entry
|
||||
sc *slack.Client
|
||||
|
||||
users map[string]*slack.User
|
||||
usersMutex sync.RWMutex
|
||||
usersSyncPoints map[string]chan struct{}
|
||||
|
||||
refreshInProgress bool
|
||||
earliestRefresh time.Time
|
||||
refreshMutex sync.Mutex
|
||||
}
|
||||
|
||||
func newUserManager(log *logrus.Entry, sc *slack.Client) *users {
|
||||
return &users{
|
||||
log: log,
|
||||
sc: sc,
|
||||
users: make(map[string]*slack.User),
|
||||
usersSyncPoints: make(map[string]chan struct{}),
|
||||
earliestRefresh: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func (b *users) getUser(id string) *slack.User {
|
||||
b.usersMutex.RLock()
|
||||
user, ok := b.users[id]
|
||||
b.usersMutex.RUnlock()
|
||||
if ok {
|
||||
return user
|
||||
}
|
||||
b.populateUser(id)
|
||||
b.usersMutex.RLock()
|
||||
defer b.usersMutex.RUnlock()
|
||||
|
||||
return b.users[id]
|
||||
}
|
||||
|
||||
func (b *users) getUsername(id string) string {
|
||||
if user := b.getUser(id); user != nil {
|
||||
if user.Profile.DisplayName != "" {
|
||||
return user.Profile.DisplayName
|
||||
}
|
||||
return user.Name
|
||||
}
|
||||
b.log.Warnf("Could not find user with ID '%s'", id)
|
||||
return ""
|
||||
}
|
||||
|
||||
func (b *users) getAvatar(id string) string {
|
||||
if user := b.getUser(id); user != nil {
|
||||
return user.Profile.Image48
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (b *users) populateUser(userID string) {
|
||||
for {
|
||||
b.usersMutex.Lock()
|
||||
_, exists := b.users[userID]
|
||||
if exists {
|
||||
// already in cache
|
||||
b.usersMutex.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
if syncPoint, ok := b.usersSyncPoints[userID]; ok {
|
||||
// Another goroutine is already populating this user for us so wait on it to finish.
|
||||
b.usersMutex.Unlock()
|
||||
<-syncPoint
|
||||
// We do not return and iterate again to check that the entry does indeed exist
|
||||
// in case the previous query failed for some reason.
|
||||
} else {
|
||||
b.usersSyncPoints[userID] = make(chan struct{})
|
||||
defer func() {
|
||||
// Wake up any waiting goroutines and remove the synchronization point.
|
||||
close(b.usersSyncPoints[userID])
|
||||
delete(b.usersSyncPoints, userID)
|
||||
}()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Do not hold the lock while fetching information from Slack
|
||||
// as this might take an unbounded amount of time.
|
||||
b.usersMutex.Unlock()
|
||||
|
||||
user, err := b.sc.GetUserInfo(userID)
|
||||
if err != nil {
|
||||
b.log.Debugf("GetUserInfo failed for %v: %v", userID, err)
|
||||
return
|
||||
}
|
||||
|
||||
b.usersMutex.Lock()
|
||||
defer b.usersMutex.Unlock()
|
||||
|
||||
// Register user information.
|
||||
b.users[userID] = user
|
||||
}
|
||||
|
||||
func (b *users) populateUsers(wait bool) {
|
||||
b.refreshMutex.Lock()
|
||||
if !wait && (time.Now().Before(b.earliestRefresh) || b.refreshInProgress) {
|
||||
b.log.Debugf("Not refreshing user list as it was done less than %v ago.", minimumRefreshInterval)
|
||||
b.refreshMutex.Unlock()
|
||||
|
||||
return
|
||||
}
|
||||
for b.refreshInProgress {
|
||||
b.refreshMutex.Unlock()
|
||||
time.Sleep(time.Second)
|
||||
b.refreshMutex.Lock()
|
||||
}
|
||||
b.refreshInProgress = true
|
||||
b.refreshMutex.Unlock()
|
||||
|
||||
newUsers := map[string]*slack.User{}
|
||||
pagination := b.sc.GetUsersPaginated(slack.GetUsersOptionLimit(200))
|
||||
count := 0
|
||||
for {
|
||||
var err error
|
||||
pagination, err = pagination.Next(context.Background())
|
||||
time.Sleep(time.Second)
|
||||
if err != nil {
|
||||
if pagination.Done(err) {
|
||||
break
|
||||
}
|
||||
|
||||
if err = handleRateLimit(b.log, err); err != nil {
|
||||
b.log.Errorf("Could not retrieve users: %#v", err)
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
for i := range pagination.Users {
|
||||
newUsers[pagination.Users[i].ID] = &pagination.Users[i]
|
||||
}
|
||||
b.log.Debugf("getting %d users", len(pagination.Users))
|
||||
count++
|
||||
// more > 2000 users, slack will complain and ratelimit. break
|
||||
if count > 10 {
|
||||
b.log.Info("Large slack detected > 2000 users, skipping loading complete userlist.")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
b.usersMutex.Lock()
|
||||
defer b.usersMutex.Unlock()
|
||||
b.users = newUsers
|
||||
|
||||
b.refreshMutex.Lock()
|
||||
defer b.refreshMutex.Unlock()
|
||||
b.earliestRefresh = time.Now().Add(minimumRefreshInterval)
|
||||
b.refreshInProgress = false
|
||||
}
|
||||
|
||||
type channels struct {
|
||||
log *logrus.Entry
|
||||
sc *slack.Client
|
||||
|
||||
channelsByID map[string]*slack.Channel
|
||||
channelsByName map[string]*slack.Channel
|
||||
channelsMutex sync.RWMutex
|
||||
|
||||
channelMembers map[string][]string
|
||||
channelMembersMutex sync.RWMutex
|
||||
|
||||
refreshInProgress bool
|
||||
earliestRefresh time.Time
|
||||
refreshMutex sync.Mutex
|
||||
}
|
||||
|
||||
func newChannelManager(log *logrus.Entry, sc *slack.Client) *channels {
|
||||
return &channels{
|
||||
log: log,
|
||||
sc: sc,
|
||||
channelsByID: make(map[string]*slack.Channel),
|
||||
channelsByName: make(map[string]*slack.Channel),
|
||||
earliestRefresh: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func (b *channels) getChannel(channel string) (*slack.Channel, error) {
|
||||
if strings.HasPrefix(channel, "ID:") {
|
||||
return b.getChannelByID(strings.TrimPrefix(channel, "ID:"))
|
||||
}
|
||||
return b.getChannelByName(channel)
|
||||
}
|
||||
|
||||
func (b *channels) getChannelByName(name string) (*slack.Channel, error) {
|
||||
return b.getChannelBy(name, b.channelsByName)
|
||||
}
|
||||
|
||||
func (b *channels) getChannelByID(id string) (*slack.Channel, error) {
|
||||
return b.getChannelBy(id, b.channelsByID)
|
||||
}
|
||||
|
||||
func (b *channels) getChannelBy(lookupKey string, lookupMap map[string]*slack.Channel) (*slack.Channel, error) {
|
||||
b.channelsMutex.RLock()
|
||||
defer b.channelsMutex.RUnlock()
|
||||
|
||||
if channel, ok := lookupMap[lookupKey]; ok {
|
||||
return channel, nil
|
||||
}
|
||||
return nil, fmt.Errorf("channel %s not found", lookupKey)
|
||||
}
|
||||
|
||||
func (b *channels) getChannelMembers(users *users) config.ChannelMembers {
|
||||
b.channelMembersMutex.RLock()
|
||||
defer b.channelMembersMutex.RUnlock()
|
||||
|
||||
membersInfo := config.ChannelMembers{}
|
||||
for channelID, members := range b.channelMembers {
|
||||
for _, member := range members {
|
||||
channelName := ""
|
||||
userName := ""
|
||||
userNick := ""
|
||||
user := users.getUser(member)
|
||||
if user != nil {
|
||||
userName = user.Name
|
||||
userNick = user.Profile.DisplayName
|
||||
}
|
||||
channel, _ := b.getChannelByID(channelID)
|
||||
if channel != nil {
|
||||
channelName = channel.Name
|
||||
}
|
||||
memberInfo := config.ChannelMember{
|
||||
Username: userName,
|
||||
Nick: userNick,
|
||||
UserID: member,
|
||||
ChannelID: channelID,
|
||||
ChannelName: channelName,
|
||||
}
|
||||
membersInfo = append(membersInfo, memberInfo)
|
||||
}
|
||||
}
|
||||
return membersInfo
|
||||
}
|
||||
|
||||
func (b *channels) registerChannel(channel slack.Channel) {
|
||||
b.channelsMutex.Lock()
|
||||
defer b.channelsMutex.Unlock()
|
||||
|
||||
b.channelsByID[channel.ID] = &channel
|
||||
b.channelsByName[channel.Name] = &channel
|
||||
}
|
||||
|
||||
func (b *channels) populateChannels(wait bool) {
|
||||
b.refreshMutex.Lock()
|
||||
if !wait && (time.Now().Before(b.earliestRefresh) || b.refreshInProgress) {
|
||||
b.log.Debugf("Not refreshing channel list as it was done less than %v seconds ago.", minimumRefreshInterval)
|
||||
b.refreshMutex.Unlock()
|
||||
return
|
||||
}
|
||||
for b.refreshInProgress {
|
||||
b.refreshMutex.Unlock()
|
||||
time.Sleep(time.Second)
|
||||
b.refreshMutex.Lock()
|
||||
}
|
||||
b.refreshInProgress = true
|
||||
b.refreshMutex.Unlock()
|
||||
|
||||
newChannelsByID := map[string]*slack.Channel{}
|
||||
newChannelsByName := map[string]*slack.Channel{}
|
||||
newChannelMembers := make(map[string][]string)
|
||||
|
||||
// We only retrieve public and private channels, not IMs
|
||||
// and MPIMs as those do not have a channel name.
|
||||
queryParams := &slack.GetConversationsParameters{
|
||||
ExcludeArchived: "true",
|
||||
Types: []string{"public_channel,private_channel"},
|
||||
}
|
||||
for {
|
||||
channels, nextCursor, err := b.sc.GetConversations(queryParams)
|
||||
if err != nil {
|
||||
if err = handleRateLimit(b.log, err); err != nil {
|
||||
b.log.Errorf("Could not retrieve channels: %#v", err)
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
for i := range channels {
|
||||
newChannelsByID[channels[i].ID] = &channels[i]
|
||||
newChannelsByName[channels[i].Name] = &channels[i]
|
||||
// also find all the members in every channel
|
||||
// comment for now, issues on big slacks
|
||||
/*
|
||||
members, err := b.getUsersInConversation(channels[i].ID)
|
||||
if err != nil {
|
||||
if err = b.handleRateLimit(err); err != nil {
|
||||
b.Log.Errorf("Could not retrieve channel members: %#v", err)
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
newChannelMembers[channels[i].ID] = members
|
||||
*/
|
||||
}
|
||||
|
||||
if nextCursor == "" {
|
||||
break
|
||||
}
|
||||
queryParams.Cursor = nextCursor
|
||||
}
|
||||
|
||||
b.channelsMutex.Lock()
|
||||
defer b.channelsMutex.Unlock()
|
||||
b.channelsByID = newChannelsByID
|
||||
b.channelsByName = newChannelsByName
|
||||
|
||||
b.channelMembersMutex.Lock()
|
||||
defer b.channelMembersMutex.Unlock()
|
||||
b.channelMembers = newChannelMembers
|
||||
|
||||
b.refreshMutex.Lock()
|
||||
defer b.refreshMutex.Unlock()
|
||||
b.earliestRefresh = time.Now().Add(minimumRefreshInterval)
|
||||
b.refreshInProgress = false
|
||||
}
|
||||
169
bridge/sshchat/sshchat.go
Normal file
169
bridge/sshchat/sshchat.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package bsshchat
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/42wim/matterbridge/bridge"
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
"github.com/42wim/matterbridge/bridge/helper"
|
||||
"github.com/shazow/ssh-chat/sshd"
|
||||
)
|
||||
|
||||
type Bsshchat struct {
|
||||
r *bufio.Scanner
|
||||
w io.WriteCloser
|
||||
*bridge.Config
|
||||
}
|
||||
|
||||
func New(cfg *bridge.Config) bridge.Bridger {
|
||||
return &Bsshchat{Config: cfg}
|
||||
}
|
||||
|
||||
func (b *Bsshchat) Connect() error {
|
||||
b.Log.Infof("Connecting %s", b.GetString("Server"))
|
||||
|
||||
// connHandler will be called by 'sshd.ConnectShell()' below
|
||||
// once the connection is established in order to handle it.
|
||||
connErr := make(chan error, 1) // Needs to be buffered.
|
||||
connSignal := make(chan struct{})
|
||||
connHandler := func(r io.Reader, w io.WriteCloser) error {
|
||||
b.r = bufio.NewScanner(r)
|
||||
b.r.Scan()
|
||||
b.w = w
|
||||
if _, err := b.w.Write([]byte("/theme mono\r\n/quiet\r\n")); err != nil {
|
||||
return err
|
||||
}
|
||||
close(connSignal) // Connection is established so we can signal the success.
|
||||
return b.handleSSHChat()
|
||||
}
|
||||
|
||||
go func() {
|
||||
// As a successful connection will result in this returning after the Connection
|
||||
// method has already returned point we NEED to have a buffered channel to still
|
||||
// be able to write.
|
||||
connErr <- sshd.ConnectShell(b.GetString("Server"), b.GetString("Nick"), connHandler)
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-connErr:
|
||||
b.Log.Error("Connection failed")
|
||||
return err
|
||||
case <-connSignal:
|
||||
}
|
||||
b.Log.Info("Connection succeeded")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bsshchat) Disconnect() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bsshchat) JoinChannel(channel config.ChannelInfo) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bsshchat) Send(msg config.Message) (string, error) {
|
||||
// ignore delete messages
|
||||
if msg.Event == config.EventMsgDelete {
|
||||
return "", nil
|
||||
}
|
||||
b.Log.Debugf("=> Receiving %#v", msg)
|
||||
if msg.Extra != nil {
|
||||
for _, rmsg := range helper.HandleExtra(&msg, b.General) {
|
||||
if _, err := b.w.Write([]byte(rmsg.Username + rmsg.Text + "\r\n")); err != nil {
|
||||
b.Log.Errorf("Could not send extra message: %#v", err)
|
||||
}
|
||||
}
|
||||
if len(msg.Extra["file"]) > 0 {
|
||||
return b.handleUploadFile(&msg)
|
||||
}
|
||||
}
|
||||
_, err := b.w.Write([]byte(msg.Username + msg.Text + "\r\n"))
|
||||
return "", err
|
||||
}
|
||||
|
||||
/*
|
||||
func (b *Bsshchat) sshchatKeepAlive() chan bool {
|
||||
done := make(chan bool)
|
||||
go func() {
|
||||
ticker := time.NewTicker(90 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
b.Log.Debugf("PING")
|
||||
err := b.xc.PingC2S("", "")
|
||||
if err != nil {
|
||||
b.Log.Debugf("PING failed %#v", err)
|
||||
}
|
||||
case <-done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
return done
|
||||
}
|
||||
*/
|
||||
|
||||
func stripPrompt(s string) string {
|
||||
pos := strings.LastIndex(s, "\033[K")
|
||||
if pos < 0 {
|
||||
return s
|
||||
}
|
||||
return s[pos+3:]
|
||||
}
|
||||
|
||||
func (b *Bsshchat) handleSSHChat() error {
|
||||
/*
|
||||
done := b.sshchatKeepAlive()
|
||||
defer close(done)
|
||||
*/
|
||||
wait := true
|
||||
for {
|
||||
if b.r.Scan() {
|
||||
// ignore messages from ourselves
|
||||
if !strings.Contains(b.r.Text(), "\033[K") {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(b.r.Text(), "Rate limiting is in effect") {
|
||||
continue
|
||||
}
|
||||
// skip our own messages
|
||||
if !strings.HasPrefix(b.r.Text(), "["+b.GetString("Nick")+"] \x1b") {
|
||||
continue
|
||||
}
|
||||
res := strings.Split(stripPrompt(b.r.Text()), ":")
|
||||
if res[0] == "-> Set theme" {
|
||||
wait = false
|
||||
b.Log.Debugf("mono found, allowing")
|
||||
continue
|
||||
}
|
||||
if !wait {
|
||||
b.Log.Debugf("<= Message %#v", res)
|
||||
rmsg := config.Message{Username: res[0], Text: strings.TrimSpace(strings.Join(res[1:], ":")), Channel: "sshchat", Account: b.Account, UserID: "nick"}
|
||||
b.Remote <- rmsg
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bsshchat) handleUploadFile(msg *config.Message) (string, error) {
|
||||
for _, f := range msg.Extra["file"] {
|
||||
fi := f.(config.FileInfo)
|
||||
if fi.Comment != "" {
|
||||
msg.Text += fi.Comment + ": "
|
||||
}
|
||||
if fi.URL != "" {
|
||||
msg.Text = fi.URL
|
||||
if fi.Comment != "" {
|
||||
msg.Text = fi.Comment + ": " + fi.URL
|
||||
}
|
||||
}
|
||||
if _, err := b.w.Write([]byte(msg.Username + msg.Text + "\r\n")); err != nil {
|
||||
b.Log.Errorf("Could not send file message: %#v", err)
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
126
bridge/steam/handlers.go
Normal file
126
bridge/steam/handlers.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package bsteam
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
"github.com/Philipp15b/go-steam"
|
||||
"github.com/Philipp15b/go-steam/protocol/steamlang"
|
||||
)
|
||||
|
||||
func (b *Bsteam) handleChatMsg(e *steam.ChatMsgEvent) {
|
||||
b.Log.Debugf("Receiving ChatMsgEvent: %#v", e)
|
||||
b.Log.Debugf("<= Sending message from %s on %s to gateway", b.getNick(e.ChatterId), b.Account)
|
||||
var channel int64
|
||||
if e.ChatRoomId == 0 {
|
||||
channel = int64(e.ChatterId)
|
||||
} else {
|
||||
// for some reason we have to remove 0x18000000000000
|
||||
// TODO
|
||||
// https://github.com/42wim/matterbridge/pull/630#discussion_r238102751
|
||||
// channel = int64(e.ChatRoomId) & 0xfffffffffffff
|
||||
channel = int64(e.ChatRoomId) - 0x18000000000000
|
||||
}
|
||||
msg := config.Message{
|
||||
Username: b.getNick(e.ChatterId),
|
||||
Text: e.Message,
|
||||
Channel: strconv.FormatInt(channel, 10),
|
||||
Account: b.Account,
|
||||
UserID: strconv.FormatInt(int64(e.ChatterId), 10),
|
||||
}
|
||||
b.Remote <- msg
|
||||
}
|
||||
|
||||
func (b *Bsteam) handleEvents() {
|
||||
myLoginInfo := &steam.LogOnDetails{
|
||||
Username: b.GetString("Login"),
|
||||
Password: b.GetString("Password"),
|
||||
AuthCode: b.GetString("AuthCode"),
|
||||
}
|
||||
// TODO Attempt to read existing auth hash to avoid steam guard.
|
||||
// Maybe works
|
||||
//myLoginInfo.SentryFileHash, _ = ioutil.ReadFile("sentry")
|
||||
for event := range b.c.Events() {
|
||||
switch e := event.(type) {
|
||||
case *steam.ChatMsgEvent:
|
||||
b.handleChatMsg(e)
|
||||
case *steam.PersonaStateEvent:
|
||||
b.Log.Debugf("PersonaStateEvent: %#v\n", e)
|
||||
b.Lock()
|
||||
b.userMap[e.FriendId] = e.Name
|
||||
b.Unlock()
|
||||
case *steam.ConnectedEvent:
|
||||
b.c.Auth.LogOn(myLoginInfo)
|
||||
case *steam.MachineAuthUpdateEvent:
|
||||
// TODO sentry files for 2 auth
|
||||
/*
|
||||
b.Log.Info("authupdate", e)
|
||||
b.Log.Info("hash", e.Hash)
|
||||
ioutil.WriteFile("sentry", e.Hash, 0666)
|
||||
*/
|
||||
case *steam.LogOnFailedEvent:
|
||||
b.Log.Info("Logon failed", e)
|
||||
err := b.handleLogOnFailed(e, myLoginInfo)
|
||||
if err != nil {
|
||||
b.Log.Error(err)
|
||||
return
|
||||
}
|
||||
case *steam.LoggedOnEvent:
|
||||
b.Log.Debugf("LoggedOnEvent: %#v", e)
|
||||
b.connected <- struct{}{}
|
||||
b.Log.Debugf("setting online")
|
||||
b.c.Social.SetPersonaState(steamlang.EPersonaState_Online)
|
||||
case *steam.DisconnectedEvent:
|
||||
b.Log.Info("Disconnected")
|
||||
b.Log.Info("Attempting to reconnect...")
|
||||
b.c.Connect()
|
||||
case steam.FatalErrorEvent:
|
||||
b.Log.Errorf("steam FatalErrorEvent: %#v", e)
|
||||
default:
|
||||
b.Log.Debugf("unknown event %#v", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bsteam) handleLogOnFailed(e *steam.LogOnFailedEvent, myLoginInfo *steam.LogOnDetails) error {
|
||||
switch e.Result {
|
||||
case steamlang.EResult_AccountLoginDeniedNeedTwoFactor:
|
||||
b.Log.Info("Steam guard isn't letting me in! Enter 2FA code:")
|
||||
var code string
|
||||
fmt.Scanf("%s", &code)
|
||||
// TODO https://github.com/42wim/matterbridge/pull/630#discussion_r238103978
|
||||
myLoginInfo.TwoFactorCode = code
|
||||
case steamlang.EResult_AccountLogonDenied:
|
||||
b.Log.Info("Steam guard isn't letting me in! Enter auth code:")
|
||||
var code string
|
||||
fmt.Scanf("%s", &code)
|
||||
// TODO https://github.com/42wim/matterbridge/pull/630#discussion_r238103978
|
||||
myLoginInfo.AuthCode = code
|
||||
case steamlang.EResult_InvalidLoginAuthCode:
|
||||
return fmt.Errorf("Steam guard: invalid login auth code: %#v ", e.Result)
|
||||
default:
|
||||
return fmt.Errorf("LogOnFailedEvent: %#v ", e.Result)
|
||||
// TODO: Handle EResult_InvalidLoginAuthCode
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleFileInfo handles config.FileInfo and adds correct file comment or URL to msg.Text.
|
||||
// Returns error if cast fails.
|
||||
func (b *Bsteam) handleFileInfo(msg *config.Message, f interface{}) error {
|
||||
if _, ok := f.(config.FileInfo); !ok {
|
||||
return fmt.Errorf("handleFileInfo cast failed %#v", f)
|
||||
}
|
||||
fi := f.(config.FileInfo)
|
||||
if fi.Comment != "" {
|
||||
msg.Text += fi.Comment + ": "
|
||||
}
|
||||
if fi.URL != "" {
|
||||
msg.Text = fi.URL
|
||||
if fi.Comment != "" {
|
||||
msg.Text = fi.Comment + ": " + fi.URL
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -2,52 +2,40 @@ package bsteam
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/42wim/matterbridge/bridge"
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
"github.com/42wim/matterbridge/bridge/helper"
|
||||
"github.com/Philipp15b/go-steam"
|
||||
"github.com/Philipp15b/go-steam/protocol/steamlang"
|
||||
"github.com/Philipp15b/go-steam/steamid"
|
||||
log "github.com/Sirupsen/logrus"
|
||||
//"io/ioutil"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Bsteam struct {
|
||||
c *steam.Client
|
||||
connected chan struct{}
|
||||
Config *config.Protocol
|
||||
Remote chan config.Message
|
||||
Account string
|
||||
userMap map[steamid.SteamId]string
|
||||
sync.RWMutex
|
||||
*bridge.Config
|
||||
}
|
||||
|
||||
var flog *log.Entry
|
||||
var protocol = "steam"
|
||||
|
||||
func init() {
|
||||
flog = log.WithFields(log.Fields{"module": protocol})
|
||||
}
|
||||
|
||||
func New(cfg config.Protocol, account string, c chan config.Message) *Bsteam {
|
||||
b := &Bsteam{}
|
||||
b.Config = &cfg
|
||||
b.Remote = c
|
||||
b.Account = account
|
||||
func New(cfg *bridge.Config) bridge.Bridger {
|
||||
b := &Bsteam{Config: cfg}
|
||||
b.userMap = make(map[steamid.SteamId]string)
|
||||
b.connected = make(chan struct{})
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *Bsteam) Connect() error {
|
||||
flog.Info("Connecting")
|
||||
b.Log.Info("Connecting")
|
||||
b.c = steam.NewClient()
|
||||
go b.handleEvents()
|
||||
go b.c.Connect()
|
||||
select {
|
||||
case <-b.connected:
|
||||
flog.Info("Connection succeeded")
|
||||
b.Log.Info("Connection succeeded")
|
||||
case <-time.After(time.Second * 30):
|
||||
return fmt.Errorf("connection timed out")
|
||||
}
|
||||
@@ -71,13 +59,28 @@ func (b *Bsteam) JoinChannel(channel config.ChannelInfo) error {
|
||||
|
||||
func (b *Bsteam) Send(msg config.Message) (string, error) {
|
||||
// ignore delete messages
|
||||
if msg.Event == config.EVENT_MSG_DELETE {
|
||||
if msg.Event == config.EventMsgDelete {
|
||||
return "", nil
|
||||
}
|
||||
id, err := steamid.NewId(msg.Channel)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Handle files
|
||||
if msg.Extra != nil {
|
||||
for _, rmsg := range helper.HandleExtra(&msg, b.General) {
|
||||
b.c.Social.SendMessage(id, steamlang.EChatEntryType_ChatMsg, rmsg.Username+rmsg.Text)
|
||||
}
|
||||
for i := range msg.Extra["file"] {
|
||||
if err := b.handleFileInfo(&msg, msg.Extra["file"][i]); err != nil {
|
||||
b.Log.Error(err)
|
||||
}
|
||||
b.c.Social.SendMessage(id, steamlang.EChatEntryType_ChatMsg, msg.Username+msg.Text)
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
b.c.Social.SendMessage(id, steamlang.EChatEntryType_ChatMsg, msg.Username+msg.Text)
|
||||
return "", nil
|
||||
}
|
||||
@@ -90,75 +93,3 @@ func (b *Bsteam) getNick(id steamid.SteamId) string {
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func (b *Bsteam) handleEvents() {
|
||||
myLoginInfo := new(steam.LogOnDetails)
|
||||
myLoginInfo.Username = b.Config.Login
|
||||
myLoginInfo.Password = b.Config.Password
|
||||
myLoginInfo.AuthCode = b.Config.AuthCode
|
||||
// Attempt to read existing auth hash to avoid steam guard.
|
||||
// Maybe works
|
||||
//myLoginInfo.SentryFileHash, _ = ioutil.ReadFile("sentry")
|
||||
for event := range b.c.Events() {
|
||||
//flog.Info(event)
|
||||
switch e := event.(type) {
|
||||
case *steam.ChatMsgEvent:
|
||||
flog.Debugf("Receiving ChatMsgEvent: %#v", e)
|
||||
flog.Debugf("Sending message from %s on %s to gateway", b.getNick(e.ChatterId), b.Account)
|
||||
// for some reason we have to remove 0x18000000000000
|
||||
channel := int64(e.ChatRoomId) - 0x18000000000000
|
||||
msg := config.Message{Username: b.getNick(e.ChatterId), Text: e.Message, Channel: strconv.FormatInt(channel, 10), Account: b.Account, UserID: strconv.FormatInt(int64(e.ChatterId), 10)}
|
||||
b.Remote <- msg
|
||||
case *steam.PersonaStateEvent:
|
||||
flog.Debugf("PersonaStateEvent: %#v\n", e)
|
||||
b.Lock()
|
||||
b.userMap[e.FriendId] = e.Name
|
||||
b.Unlock()
|
||||
case *steam.ConnectedEvent:
|
||||
b.c.Auth.LogOn(myLoginInfo)
|
||||
case *steam.MachineAuthUpdateEvent:
|
||||
/*
|
||||
flog.Info("authupdate", e)
|
||||
flog.Info("hash", e.Hash)
|
||||
ioutil.WriteFile("sentry", e.Hash, 0666)
|
||||
*/
|
||||
case *steam.LogOnFailedEvent:
|
||||
flog.Info("Logon failed", e)
|
||||
switch e.Result {
|
||||
case steamlang.EResult_AccountLogonDeniedNeedTwoFactorCode:
|
||||
{
|
||||
flog.Info("Steam guard isn't letting me in! Enter 2FA code:")
|
||||
var code string
|
||||
fmt.Scanf("%s", &code)
|
||||
myLoginInfo.TwoFactorCode = code
|
||||
}
|
||||
case steamlang.EResult_AccountLogonDenied:
|
||||
{
|
||||
flog.Info("Steam guard isn't letting me in! Enter auth code:")
|
||||
var code string
|
||||
fmt.Scanf("%s", &code)
|
||||
myLoginInfo.AuthCode = code
|
||||
}
|
||||
default:
|
||||
log.Errorf("LogOnFailedEvent: %#v ", e.Result)
|
||||
// TODO: Handle EResult_InvalidLoginAuthCode
|
||||
return
|
||||
}
|
||||
case *steam.LoggedOnEvent:
|
||||
flog.Debugf("LoggedOnEvent: %#v", e)
|
||||
b.connected <- struct{}{}
|
||||
flog.Debugf("setting online")
|
||||
b.c.Social.SetPersonaState(steamlang.EPersonaState_Online)
|
||||
case *steam.DisconnectedEvent:
|
||||
flog.Info("Disconnected")
|
||||
flog.Info("Attempting to reconnect...")
|
||||
b.c.Connect()
|
||||
case steam.FatalErrorEvent:
|
||||
flog.Error(e)
|
||||
case error:
|
||||
flog.Error(e)
|
||||
default:
|
||||
flog.Debugf("unknown event %#v", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
452
bridge/telegram/handlers.go
Normal file
452
bridge/telegram/handlers.go
Normal file
@@ -0,0 +1,452 @@
|
||||
package btelegram
|
||||
|
||||
import (
|
||||
"html"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf16"
|
||||
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
"github.com/42wim/matterbridge/bridge/helper"
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api"
|
||||
)
|
||||
|
||||
func (b *Btelegram) handleUpdate(rmsg *config.Message, message, posted, edited *tgbotapi.Message) *tgbotapi.Message {
|
||||
// handle channels
|
||||
if posted != nil {
|
||||
message = posted
|
||||
rmsg.Text = message.Text
|
||||
}
|
||||
|
||||
// edited channel message
|
||||
if edited != nil && !b.GetBool("EditDisable") {
|
||||
message = edited
|
||||
rmsg.Text = rmsg.Text + message.Text + b.GetString("EditSuffix")
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
// handleChannels checks if it's a channel message and if the message is a new or edited messages
|
||||
func (b *Btelegram) handleChannels(rmsg *config.Message, message *tgbotapi.Message, update tgbotapi.Update) *tgbotapi.Message {
|
||||
return b.handleUpdate(rmsg, message, update.ChannelPost, update.EditedChannelPost)
|
||||
}
|
||||
|
||||
// handleGroups checks if it's a group message and if the message is a new or edited messages
|
||||
func (b *Btelegram) handleGroups(rmsg *config.Message, message *tgbotapi.Message, update tgbotapi.Update) *tgbotapi.Message {
|
||||
return b.handleUpdate(rmsg, message, update.Message, update.EditedMessage)
|
||||
}
|
||||
|
||||
// handleForwarded handles forwarded messages
|
||||
func (b *Btelegram) handleForwarded(rmsg *config.Message, message *tgbotapi.Message) {
|
||||
if message.ForwardDate == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if message.ForwardFrom == nil {
|
||||
rmsg.Text = "Forwarded from " + unknownUser + ": " + rmsg.Text
|
||||
return
|
||||
}
|
||||
|
||||
usernameForward := ""
|
||||
if b.GetBool("UseFirstName") {
|
||||
usernameForward = message.ForwardFrom.FirstName
|
||||
}
|
||||
|
||||
if usernameForward == "" {
|
||||
usernameForward = message.ForwardFrom.UserName
|
||||
if usernameForward == "" {
|
||||
usernameForward = message.ForwardFrom.FirstName
|
||||
}
|
||||
}
|
||||
|
||||
if usernameForward == "" {
|
||||
usernameForward = unknownUser
|
||||
}
|
||||
|
||||
rmsg.Text = "Forwarded from " + usernameForward + ": " + rmsg.Text
|
||||
}
|
||||
|
||||
// handleQuoting handles quoting of previous messages
|
||||
func (b *Btelegram) handleQuoting(rmsg *config.Message, message *tgbotapi.Message) {
|
||||
if message.ReplyToMessage != nil {
|
||||
usernameReply := ""
|
||||
if message.ReplyToMessage.From != nil {
|
||||
if b.GetBool("UseFirstName") {
|
||||
usernameReply = message.ReplyToMessage.From.FirstName
|
||||
}
|
||||
if usernameReply == "" {
|
||||
usernameReply = message.ReplyToMessage.From.UserName
|
||||
if usernameReply == "" {
|
||||
usernameReply = message.ReplyToMessage.From.FirstName
|
||||
}
|
||||
}
|
||||
}
|
||||
if usernameReply == "" {
|
||||
usernameReply = unknownUser
|
||||
}
|
||||
if !b.GetBool("QuoteDisable") {
|
||||
rmsg.Text = b.handleQuote(rmsg.Text, usernameReply, message.ReplyToMessage.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleUsername handles the correct setting of the username
|
||||
func (b *Btelegram) handleUsername(rmsg *config.Message, message *tgbotapi.Message) {
|
||||
if message.From != nil {
|
||||
rmsg.UserID = strconv.Itoa(message.From.ID)
|
||||
if b.GetBool("UseFirstName") {
|
||||
rmsg.Username = message.From.FirstName
|
||||
}
|
||||
if rmsg.Username == "" {
|
||||
rmsg.Username = message.From.UserName
|
||||
if rmsg.Username == "" {
|
||||
rmsg.Username = message.From.FirstName
|
||||
}
|
||||
}
|
||||
// only download avatars if we have a place to upload them (configured mediaserver)
|
||||
if b.General.MediaServerUpload != "" || (b.General.MediaServerDownload != "" && b.General.MediaDownloadPath != "") {
|
||||
b.handleDownloadAvatar(message.From.ID, rmsg.Channel)
|
||||
}
|
||||
}
|
||||
|
||||
// if we really didn't find a username, set it to unknown
|
||||
if rmsg.Username == "" {
|
||||
rmsg.Username = unknownUser
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Btelegram) handleRecv(updates <-chan tgbotapi.Update) {
|
||||
for update := range updates {
|
||||
b.Log.Debugf("== Receiving event: %#v", update.Message)
|
||||
|
||||
if update.Message == nil && update.ChannelPost == nil &&
|
||||
update.EditedMessage == nil && update.EditedChannelPost == nil {
|
||||
b.Log.Error("Getting nil messages, this shouldn't happen.")
|
||||
continue
|
||||
}
|
||||
|
||||
var message *tgbotapi.Message
|
||||
|
||||
rmsg := config.Message{Account: b.Account, Extra: make(map[string][]interface{})}
|
||||
|
||||
// handle channels
|
||||
message = b.handleChannels(&rmsg, message, update)
|
||||
|
||||
// handle groups
|
||||
message = b.handleGroups(&rmsg, message, update)
|
||||
|
||||
if message == nil {
|
||||
b.Log.Error("message is nil, this shouldn't happen.")
|
||||
continue
|
||||
}
|
||||
|
||||
// set the ID's from the channel or group message
|
||||
rmsg.ID = strconv.Itoa(message.MessageID)
|
||||
rmsg.Channel = strconv.FormatInt(message.Chat.ID, 10)
|
||||
|
||||
// handle username
|
||||
b.handleUsername(&rmsg, message)
|
||||
|
||||
// handle any downloads
|
||||
err := b.handleDownload(&rmsg, message)
|
||||
if err != nil {
|
||||
b.Log.Errorf("download failed: %s", err)
|
||||
}
|
||||
|
||||
// handle forwarded messages
|
||||
b.handleForwarded(&rmsg, message)
|
||||
|
||||
// quote the previous message
|
||||
b.handleQuoting(&rmsg, message)
|
||||
|
||||
// handle entities (adding URLs)
|
||||
b.handleEntities(&rmsg, message)
|
||||
|
||||
if rmsg.Text != "" || len(rmsg.Extra) > 0 {
|
||||
rmsg.Text = helper.RemoveEmptyNewLines(rmsg.Text)
|
||||
// channels don't have (always?) user information. see #410
|
||||
if message.From != nil {
|
||||
rmsg.Avatar = helper.GetAvatar(b.avatarMap, strconv.Itoa(message.From.ID), b.General)
|
||||
}
|
||||
|
||||
b.Log.Debugf("<= Sending message from %s on %s to gateway", rmsg.Username, b.Account)
|
||||
b.Log.Debugf("<= Message is %#v", rmsg)
|
||||
b.Remote <- rmsg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleDownloadAvatar downloads the avatar of userid from channel
|
||||
// sends a EVENT_AVATAR_DOWNLOAD message to the gateway if successful.
|
||||
// logs an error message if it fails
|
||||
func (b *Btelegram) handleDownloadAvatar(userid int, channel string) {
|
||||
rmsg := config.Message{Username: "system",
|
||||
Text: "avatar",
|
||||
Channel: channel,
|
||||
Account: b.Account,
|
||||
UserID: strconv.Itoa(userid),
|
||||
Event: config.EventAvatarDownload,
|
||||
Extra: make(map[string][]interface{})}
|
||||
|
||||
if _, ok := b.avatarMap[strconv.Itoa(userid)]; !ok {
|
||||
photos, err := b.c.GetUserProfilePhotos(tgbotapi.UserProfilePhotosConfig{UserID: userid, Limit: 1})
|
||||
if err != nil {
|
||||
b.Log.Errorf("Userprofile download failed for %#v %s", userid, err)
|
||||
}
|
||||
|
||||
if len(photos.Photos) > 0 {
|
||||
photo := photos.Photos[0][0]
|
||||
url := b.getFileDirectURL(photo.FileID)
|
||||
name := strconv.Itoa(userid) + ".png"
|
||||
b.Log.Debugf("trying to download %#v fileid %#v with size %#v", name, photo.FileID, photo.FileSize)
|
||||
|
||||
err := helper.HandleDownloadSize(b.Log, &rmsg, name, int64(photo.FileSize), b.General)
|
||||
if err != nil {
|
||||
b.Log.Error(err)
|
||||
return
|
||||
}
|
||||
data, err := helper.DownloadFile(url)
|
||||
if err != nil {
|
||||
b.Log.Errorf("download %s failed %#v", url, err)
|
||||
return
|
||||
}
|
||||
helper.HandleDownloadData(b.Log, &rmsg, name, rmsg.Text, "", data, b.General)
|
||||
b.Remote <- rmsg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Btelegram) maybeConvertTgs(name *string, data *[]byte) {
|
||||
var format string
|
||||
switch b.GetString("MediaConvertTgs") {
|
||||
case FormatWebp:
|
||||
b.Log.Debugf("Tgs to WebP conversion enabled, converting %v", name)
|
||||
format = FormatWebp
|
||||
case FormatPng:
|
||||
// The WebP to PNG converter can't handle animated webp files yet,
|
||||
// and I'm not going to write a path for x/image/webp.
|
||||
// The error message would be:
|
||||
// conversion failed: webp: non-Alpha VP8X is not implemented
|
||||
// So instead, we tell lottie to directly go to PNG.
|
||||
b.Log.Debugf("Tgs to PNG conversion enabled, converting %v", name)
|
||||
format = FormatPng
|
||||
default:
|
||||
// Otherwise, no conversion was requested. Trying to run the usual webp
|
||||
// converter would fail, because '.tgs.webp' is actually a gzipped JSON
|
||||
// file, and has nothing to do with WebP.
|
||||
return
|
||||
}
|
||||
err := helper.ConvertTgsToX(data, format, b.Log)
|
||||
if err != nil {
|
||||
b.Log.Errorf("conversion failed: %v", err)
|
||||
} else {
|
||||
*name = strings.Replace(*name, "tgs.webp", format, 1)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Btelegram) maybeConvertWebp(name *string, data *[]byte) {
|
||||
if b.GetBool("MediaConvertWebPToPNG") {
|
||||
b.Log.Debugf("WebP to PNG conversion enabled, converting %v", name)
|
||||
err := helper.ConvertWebPToPNG(data)
|
||||
if err != nil {
|
||||
b.Log.Errorf("conversion failed: %v", err)
|
||||
} else {
|
||||
*name = strings.Replace(*name, ".webp", ".png", 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleDownloadFile handles file download
|
||||
func (b *Btelegram) handleDownload(rmsg *config.Message, message *tgbotapi.Message) error {
|
||||
size := 0
|
||||
var url, name, text string
|
||||
switch {
|
||||
case message.Sticker != nil:
|
||||
text, name, url = b.getDownloadInfo(message.Sticker.FileID, ".webp", true)
|
||||
size = message.Sticker.FileSize
|
||||
case message.Voice != nil:
|
||||
text, name, url = b.getDownloadInfo(message.Voice.FileID, ".ogg", true)
|
||||
size = message.Voice.FileSize
|
||||
case message.Video != nil:
|
||||
text, name, url = b.getDownloadInfo(message.Video.FileID, "", true)
|
||||
size = message.Video.FileSize
|
||||
case message.Audio != nil:
|
||||
text, name, url = b.getDownloadInfo(message.Audio.FileID, "", true)
|
||||
size = message.Audio.FileSize
|
||||
case message.Document != nil:
|
||||
_, _, url = b.getDownloadInfo(message.Document.FileID, "", false)
|
||||
size = message.Document.FileSize
|
||||
name = message.Document.FileName
|
||||
text = " " + message.Document.FileName + " : " + url
|
||||
case message.Photo != nil:
|
||||
photos := *message.Photo
|
||||
size = photos[len(photos)-1].FileSize
|
||||
text, name, url = b.getDownloadInfo(photos[len(photos)-1].FileID, "", true)
|
||||
}
|
||||
|
||||
// if name is empty we didn't match a thing to download
|
||||
if name == "" {
|
||||
return nil
|
||||
}
|
||||
// use the URL instead of native upload
|
||||
if b.GetBool("UseInsecureURL") {
|
||||
b.Log.Debugf("Setting message text to :%s", text)
|
||||
rmsg.Text += text
|
||||
return nil
|
||||
}
|
||||
// if we have a file attached, download it (in memory) and put a pointer to it in msg.Extra
|
||||
err := helper.HandleDownloadSize(b.Log, rmsg, name, int64(size), b.General)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := helper.DownloadFile(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if strings.HasSuffix(name, ".tgs.webp") {
|
||||
b.maybeConvertTgs(&name, data)
|
||||
} else if strings.HasSuffix(name, ".webp") {
|
||||
b.maybeConvertWebp(&name, data)
|
||||
}
|
||||
|
||||
// rename .oga to .ogg https://github.com/42wim/matterbridge/issues/906#issuecomment-741793512
|
||||
if strings.HasSuffix(name, ".oga") && message.Audio != nil {
|
||||
name = strings.Replace(name, ".oga", ".ogg", 1)
|
||||
}
|
||||
|
||||
helper.HandleDownloadData(b.Log, rmsg, name, message.Caption, "", data, b.General)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Btelegram) getDownloadInfo(id string, suffix string, urlpart bool) (string, string, string) {
|
||||
url := b.getFileDirectURL(id)
|
||||
name := ""
|
||||
if urlpart {
|
||||
urlPart := strings.Split(url, "/")
|
||||
name = urlPart[len(urlPart)-1]
|
||||
}
|
||||
if suffix != "" && !strings.HasSuffix(name, suffix) {
|
||||
name += suffix
|
||||
}
|
||||
text := " " + url
|
||||
return text, name, url
|
||||
}
|
||||
|
||||
// handleDelete handles message deleting
|
||||
func (b *Btelegram) handleDelete(msg *config.Message, chatid int64) (string, error) {
|
||||
if msg.ID == "" {
|
||||
return "", nil
|
||||
}
|
||||
msgid, err := strconv.Atoi(msg.ID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, err = b.c.DeleteMessage(tgbotapi.DeleteMessageConfig{ChatID: chatid, MessageID: msgid})
|
||||
return "", err
|
||||
}
|
||||
|
||||
// handleEdit handles message editing.
|
||||
func (b *Btelegram) handleEdit(msg *config.Message, chatid int64) (string, error) {
|
||||
msgid, err := strconv.Atoi(msg.ID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.ToLower(b.GetString("MessageFormat")) == HTMLNick {
|
||||
b.Log.Debug("Using mode HTML - nick only")
|
||||
msg.Text = html.EscapeString(msg.Text)
|
||||
}
|
||||
m := tgbotapi.NewEditMessageText(chatid, msgid, msg.Username+msg.Text)
|
||||
switch b.GetString("MessageFormat") {
|
||||
case HTMLFormat:
|
||||
b.Log.Debug("Using mode HTML")
|
||||
m.ParseMode = tgbotapi.ModeHTML
|
||||
case "Markdown":
|
||||
b.Log.Debug("Using mode markdown")
|
||||
m.ParseMode = tgbotapi.ModeMarkdown
|
||||
case MarkdownV2:
|
||||
b.Log.Debug("Using mode MarkdownV2")
|
||||
m.ParseMode = MarkdownV2
|
||||
}
|
||||
if strings.ToLower(b.GetString("MessageFormat")) == HTMLNick {
|
||||
b.Log.Debug("Using mode HTML - nick only")
|
||||
m.ParseMode = tgbotapi.ModeHTML
|
||||
}
|
||||
_, err = b.c.Send(m)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// handleUploadFile handles native upload of files
|
||||
func (b *Btelegram) handleUploadFile(msg *config.Message, chatid int64) string {
|
||||
var c tgbotapi.Chattable
|
||||
for _, f := range msg.Extra["file"] {
|
||||
fi := f.(config.FileInfo)
|
||||
file := tgbotapi.FileBytes{
|
||||
Name: fi.Name,
|
||||
Bytes: *fi.Data,
|
||||
}
|
||||
re := regexp.MustCompile(".(jpg|jpe|png)$")
|
||||
if re.MatchString(fi.Name) {
|
||||
c = tgbotapi.NewPhotoUpload(chatid, file)
|
||||
} else {
|
||||
c = tgbotapi.NewDocumentUpload(chatid, file)
|
||||
}
|
||||
_, err := b.c.Send(c)
|
||||
if err != nil {
|
||||
b.Log.Errorf("file upload failed: %#v", err)
|
||||
}
|
||||
if fi.Comment != "" {
|
||||
if _, err := b.sendMessage(chatid, msg.Username, fi.Comment); err != nil {
|
||||
b.Log.Errorf("posting file comment %s failed: %s", fi.Comment, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (b *Btelegram) handleQuote(message, quoteNick, quoteMessage string) string {
|
||||
format := b.GetString("quoteformat")
|
||||
if format == "" {
|
||||
format = "{MESSAGE} (re @{QUOTENICK}: {QUOTEMESSAGE})"
|
||||
}
|
||||
quoteMessagelength := len(quoteMessage)
|
||||
if b.GetInt("QuoteLengthLimit") != 0 && quoteMessagelength >= b.GetInt("QuoteLengthLimit") {
|
||||
runes := []rune(quoteMessage)
|
||||
quoteMessage = string(runes[0:b.GetInt("QuoteLengthLimit")])
|
||||
if quoteMessagelength > b.GetInt("QuoteLengthLimit") {
|
||||
quoteMessage += "..."
|
||||
}
|
||||
}
|
||||
format = strings.Replace(format, "{MESSAGE}", message, -1)
|
||||
format = strings.Replace(format, "{QUOTENICK}", quoteNick, -1)
|
||||
format = strings.Replace(format, "{QUOTEMESSAGE}", quoteMessage, -1)
|
||||
return format
|
||||
}
|
||||
|
||||
// handleEntities handles messageEntities
|
||||
func (b *Btelegram) handleEntities(rmsg *config.Message, message *tgbotapi.Message) {
|
||||
if message.Entities == nil {
|
||||
return
|
||||
}
|
||||
// for now only do URL replacements
|
||||
for _, e := range *message.Entities {
|
||||
if e.Type == "text_link" {
|
||||
url, err := e.ParseURL()
|
||||
if err != nil {
|
||||
b.Log.Errorf("entity text_link url parse failed: %s", err)
|
||||
continue
|
||||
}
|
||||
utfEncodedString := utf16.Encode([]rune(rmsg.Text))
|
||||
if e.Offset+e.Length > len(utfEncodedString) {
|
||||
b.Log.Errorf("entity length is too long %d > %d", e.Offset+e.Length, len(utfEncodedString))
|
||||
continue
|
||||
}
|
||||
link := utf16.Decode(utfEncodedString[e.Offset : e.Offset+e.Length])
|
||||
rmsg.Text = strings.Replace(rmsg.Text, string(link), url.String(), 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,16 @@ package btelegram
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/russross/blackfriday"
|
||||
"html"
|
||||
|
||||
"github.com/russross/blackfriday"
|
||||
)
|
||||
|
||||
type customHtml struct {
|
||||
type customHTML struct {
|
||||
blackfriday.Renderer
|
||||
}
|
||||
|
||||
func (options *customHtml) Paragraph(out *bytes.Buffer, text func() bool) {
|
||||
func (options *customHTML) Paragraph(out *bytes.Buffer, text func() bool) {
|
||||
marker := out.Len()
|
||||
|
||||
if !text() {
|
||||
@@ -20,32 +21,32 @@ func (options *customHtml) Paragraph(out *bytes.Buffer, text func() bool) {
|
||||
out.WriteString("\n")
|
||||
}
|
||||
|
||||
func (options *customHtml) BlockCode(out *bytes.Buffer, text []byte, lang string) {
|
||||
func (options *customHTML) BlockCode(out *bytes.Buffer, text []byte, lang string) {
|
||||
out.WriteString("<pre>")
|
||||
|
||||
out.WriteString(html.EscapeString(string(text)))
|
||||
out.WriteString("</pre>\n")
|
||||
}
|
||||
|
||||
func (options *customHtml) Header(out *bytes.Buffer, text func() bool, level int, id string) {
|
||||
func (options *customHTML) Header(out *bytes.Buffer, text func() bool, level int, id string) {
|
||||
options.Paragraph(out, text)
|
||||
}
|
||||
|
||||
func (options *customHtml) HRule(out *bytes.Buffer) {
|
||||
out.WriteByte('\n')
|
||||
func (options *customHTML) HRule(out *bytes.Buffer) {
|
||||
out.WriteByte('\n') //nolint:errcheck
|
||||
}
|
||||
|
||||
func (options *customHtml) BlockQuote(out *bytes.Buffer, text []byte) {
|
||||
func (options *customHTML) BlockQuote(out *bytes.Buffer, text []byte) {
|
||||
out.WriteString("> ")
|
||||
out.Write(text)
|
||||
out.WriteByte('\n')
|
||||
}
|
||||
|
||||
func (options *customHtml) List(out *bytes.Buffer, text func() bool, flags int) {
|
||||
func (options *customHTML) List(out *bytes.Buffer, text func() bool, flags int) {
|
||||
options.Paragraph(out, text)
|
||||
}
|
||||
|
||||
func (options *customHtml) ListItem(out *bytes.Buffer, text []byte, flags int) {
|
||||
func (options *customHTML) ListItem(out *bytes.Buffer, text []byte, flags int) {
|
||||
out.WriteString("- ")
|
||||
out.Write(text)
|
||||
out.WriteByte('\n')
|
||||
@@ -53,7 +54,7 @@ func (options *customHtml) ListItem(out *bytes.Buffer, text []byte, flags int) {
|
||||
|
||||
func makeHTML(input string) string {
|
||||
return string(blackfriday.Markdown([]byte(input),
|
||||
&customHtml{blackfriday.HtmlRenderer(blackfriday.HTML_USE_XHTML|blackfriday.HTML_SKIP_IMAGES, "", "")},
|
||||
&customHTML{blackfriday.HtmlRenderer(blackfriday.HTML_USE_XHTML|blackfriday.HTML_SKIP_IMAGES, "", "")},
|
||||
blackfriday.EXTENSION_NO_INTRA_EMPHASIS|
|
||||
blackfriday.EXTENSION_FENCED_CODE|
|
||||
blackfriday.EXTENSION_AUTOLINK|
|
||||
|
||||
@@ -1,56 +1,68 @@
|
||||
package btelegram
|
||||
|
||||
import (
|
||||
"html"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/42wim/matterbridge/bridge"
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
log "github.com/Sirupsen/logrus"
|
||||
"github.com/go-telegram-bot-api/telegram-bot-api"
|
||||
"github.com/42wim/matterbridge/bridge/helper"
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api"
|
||||
)
|
||||
|
||||
const (
|
||||
unknownUser = "unknown"
|
||||
HTMLFormat = "HTML"
|
||||
HTMLNick = "htmlnick"
|
||||
MarkdownV2 = "MarkdownV2"
|
||||
FormatPng = "png"
|
||||
FormatWebp = "webp"
|
||||
)
|
||||
|
||||
type Btelegram struct {
|
||||
c *tgbotapi.BotAPI
|
||||
Config *config.Protocol
|
||||
Remote chan config.Message
|
||||
Account string
|
||||
c *tgbotapi.BotAPI
|
||||
*bridge.Config
|
||||
avatarMap map[string]string // keep cache of userid and avatar sha
|
||||
}
|
||||
|
||||
var flog *log.Entry
|
||||
var protocol = "telegram"
|
||||
|
||||
func init() {
|
||||
flog = log.WithFields(log.Fields{"module": protocol})
|
||||
}
|
||||
|
||||
func New(cfg config.Protocol, account string, c chan config.Message) *Btelegram {
|
||||
b := &Btelegram{}
|
||||
b.Config = &cfg
|
||||
b.Remote = c
|
||||
b.Account = account
|
||||
return b
|
||||
func New(cfg *bridge.Config) bridge.Bridger {
|
||||
tgsConvertFormat := cfg.GetString("MediaConvertTgs")
|
||||
if tgsConvertFormat != "" {
|
||||
err := helper.CanConvertTgsToX()
|
||||
if err != nil {
|
||||
log.Fatalf("Telegram bridge configured to convert .tgs files to '%s', but lottie does not appear to work:\n%#v", tgsConvertFormat, err)
|
||||
}
|
||||
if tgsConvertFormat != FormatPng && tgsConvertFormat != FormatWebp {
|
||||
log.Fatalf("Telegram bridge configured to convert .tgs files to '%s', but only '%s' and '%s' are supported.", FormatPng, FormatWebp, tgsConvertFormat)
|
||||
}
|
||||
}
|
||||
return &Btelegram{Config: cfg, avatarMap: make(map[string]string)}
|
||||
}
|
||||
|
||||
func (b *Btelegram) Connect() error {
|
||||
var err error
|
||||
flog.Info("Connecting")
|
||||
b.c, err = tgbotapi.NewBotAPI(b.Config.Token)
|
||||
b.Log.Info("Connecting")
|
||||
b.c, err = tgbotapi.NewBotAPI(b.GetString("Token"))
|
||||
if err != nil {
|
||||
flog.Debugf("%#v", err)
|
||||
b.Log.Debugf("%#v", err)
|
||||
return err
|
||||
}
|
||||
updates, err := b.c.GetUpdatesChan(tgbotapi.NewUpdate(0))
|
||||
u := tgbotapi.NewUpdate(0)
|
||||
u.Timeout = 60
|
||||
updates, err := b.c.GetUpdatesChan(u)
|
||||
if err != nil {
|
||||
flog.Debugf("%#v", err)
|
||||
b.Log.Debugf("%#v", err)
|
||||
return err
|
||||
}
|
||||
flog.Info("Connection succeeded")
|
||||
b.Log.Info("Connection succeeded")
|
||||
go b.handleRecv(updates)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Btelegram) Disconnect() error {
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
func (b *Btelegram) JoinChannel(channel config.ChannelInfo) error {
|
||||
@@ -58,136 +70,55 @@ func (b *Btelegram) JoinChannel(channel config.ChannelInfo) error {
|
||||
}
|
||||
|
||||
func (b *Btelegram) Send(msg config.Message) (string, error) {
|
||||
flog.Debugf("Receiving %#v", msg)
|
||||
b.Log.Debugf("=> Receiving %#v", msg)
|
||||
|
||||
// get the chatid
|
||||
chatid, err := strconv.ParseInt(msg.Channel, 10, 64)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if b.Config.MessageFormat == "HTML" {
|
||||
// map the file SHA to our user (caches the avatar)
|
||||
if msg.Event == config.EventAvatarDownload {
|
||||
return b.cacheAvatar(&msg)
|
||||
}
|
||||
|
||||
if b.GetString("MessageFormat") == HTMLFormat {
|
||||
msg.Text = makeHTML(msg.Text)
|
||||
}
|
||||
|
||||
if msg.Event == config.EVENT_MSG_DELETE {
|
||||
if msg.ID == "" {
|
||||
return "", nil
|
||||
// Delete message
|
||||
if msg.Event == config.EventMsgDelete {
|
||||
return b.handleDelete(&msg, chatid)
|
||||
}
|
||||
|
||||
// Upload a file if it exists
|
||||
if msg.Extra != nil {
|
||||
for _, rmsg := range helper.HandleExtra(&msg, b.General) {
|
||||
if _, msgErr := b.sendMessage(chatid, rmsg.Username, rmsg.Text); msgErr != nil {
|
||||
b.Log.Errorf("sendMessage failed: %s", msgErr)
|
||||
}
|
||||
}
|
||||
msgid, err := strconv.Atoi(msg.ID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
// check if we have files to upload (from slack, telegram or mattermost)
|
||||
if len(msg.Extra["file"]) > 0 {
|
||||
b.handleUploadFile(&msg, chatid)
|
||||
}
|
||||
_, err = b.c.DeleteMessage(tgbotapi.DeleteMessageConfig{ChatID: chatid, MessageID: msgid})
|
||||
return "", err
|
||||
}
|
||||
|
||||
// edit the message if we have a msg ID
|
||||
if msg.ID != "" {
|
||||
msgid, err := strconv.Atoi(msg.ID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
m := tgbotapi.NewEditMessageText(chatid, msgid, msg.Username+msg.Text)
|
||||
_, err = b.c.Send(m)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "", nil
|
||||
return b.handleEdit(&msg, chatid)
|
||||
}
|
||||
|
||||
m := tgbotapi.NewMessage(chatid, msg.Username+msg.Text)
|
||||
if b.Config.MessageFormat == "HTML" {
|
||||
m.ParseMode = tgbotapi.ModeHTML
|
||||
// Post normal message
|
||||
// TODO: recheck it.
|
||||
// Ignore empty text field needs for prevent double messages from whatsapp to telegram
|
||||
// when sending media with text caption
|
||||
if msg.Text != "" {
|
||||
return b.sendMessage(chatid, msg.Username, msg.Text)
|
||||
}
|
||||
res, err := b.c.Send(m)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strconv.Itoa(res.MessageID), nil
|
||||
|
||||
}
|
||||
|
||||
func (b *Btelegram) handleRecv(updates <-chan tgbotapi.Update) {
|
||||
for update := range updates {
|
||||
flog.Debugf("Receiving from telegram: %#v", update.Message)
|
||||
var message *tgbotapi.Message
|
||||
username := ""
|
||||
channel := ""
|
||||
text := ""
|
||||
// handle channels
|
||||
if update.ChannelPost != nil {
|
||||
message = update.ChannelPost
|
||||
}
|
||||
if update.EditedChannelPost != nil && !b.Config.EditDisable {
|
||||
message = update.EditedChannelPost
|
||||
message.Text = message.Text + b.Config.EditSuffix
|
||||
}
|
||||
// handle groups
|
||||
if update.Message != nil {
|
||||
message = update.Message
|
||||
}
|
||||
if update.EditedMessage != nil && !b.Config.EditDisable {
|
||||
message = update.EditedMessage
|
||||
message.Text = message.Text + b.Config.EditSuffix
|
||||
}
|
||||
if message.From != nil {
|
||||
if b.Config.UseFirstName {
|
||||
username = message.From.FirstName
|
||||
}
|
||||
if username == "" {
|
||||
username = message.From.UserName
|
||||
if username == "" {
|
||||
username = message.From.FirstName
|
||||
}
|
||||
}
|
||||
text = message.Text
|
||||
channel = strconv.FormatInt(message.Chat.ID, 10)
|
||||
}
|
||||
|
||||
if username == "" {
|
||||
username = "unknown"
|
||||
}
|
||||
if message.Sticker != nil && b.Config.UseInsecureURL {
|
||||
text = text + " " + b.getFileDirectURL(message.Sticker.FileID)
|
||||
}
|
||||
if message.Video != nil && b.Config.UseInsecureURL {
|
||||
text = text + " " + b.getFileDirectURL(message.Video.FileID)
|
||||
}
|
||||
if message.Photo != nil && b.Config.UseInsecureURL {
|
||||
photos := *message.Photo
|
||||
// last photo is the biggest
|
||||
text = text + " " + b.getFileDirectURL(photos[len(photos)-1].FileID)
|
||||
}
|
||||
if message.Document != nil && b.Config.UseInsecureURL {
|
||||
text = text + " " + message.Document.FileName + " : " + b.getFileDirectURL(message.Document.FileID)
|
||||
}
|
||||
|
||||
// quote the previous message
|
||||
if message.ReplyToMessage != nil {
|
||||
usernameReply := ""
|
||||
if message.ReplyToMessage.From != nil {
|
||||
if b.Config.UseFirstName {
|
||||
usernameReply = message.ReplyToMessage.From.FirstName
|
||||
}
|
||||
if usernameReply == "" {
|
||||
usernameReply = message.ReplyToMessage.From.UserName
|
||||
if usernameReply == "" {
|
||||
usernameReply = message.ReplyToMessage.From.FirstName
|
||||
}
|
||||
}
|
||||
}
|
||||
if usernameReply == "" {
|
||||
usernameReply = "unknown"
|
||||
}
|
||||
text = text + " (re @" + usernameReply + ":" + message.ReplyToMessage.Text + ")"
|
||||
}
|
||||
|
||||
if text != "" {
|
||||
flog.Debugf("Sending message from %s on %s to gateway", username, b.Account)
|
||||
msg := config.Message{Username: username, Text: text, Channel: channel, Account: b.Account, UserID: strconv.Itoa(message.From.ID), ID: strconv.Itoa(message.MessageID)}
|
||||
flog.Debugf("Message is %#v", msg)
|
||||
b.Remote <- msg
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (b *Btelegram) getFileDirectURL(id string) string {
|
||||
@@ -197,3 +128,44 @@ func (b *Btelegram) getFileDirectURL(id string) string {
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func (b *Btelegram) sendMessage(chatid int64, username, text string) (string, error) {
|
||||
m := tgbotapi.NewMessage(chatid, "")
|
||||
m.Text = username + text
|
||||
if b.GetString("MessageFormat") == HTMLFormat {
|
||||
b.Log.Debug("Using mode HTML")
|
||||
m.ParseMode = tgbotapi.ModeHTML
|
||||
}
|
||||
if b.GetString("MessageFormat") == "Markdown" {
|
||||
b.Log.Debug("Using mode markdown")
|
||||
m.ParseMode = tgbotapi.ModeMarkdown
|
||||
}
|
||||
if b.GetString("MessageFormat") == MarkdownV2 {
|
||||
b.Log.Debug("Using mode MarkdownV2")
|
||||
m.ParseMode = MarkdownV2
|
||||
}
|
||||
if strings.ToLower(b.GetString("MessageFormat")) == HTMLNick {
|
||||
b.Log.Debug("Using mode HTML - nick only")
|
||||
m.Text = username + html.EscapeString(text)
|
||||
m.ParseMode = tgbotapi.ModeHTML
|
||||
}
|
||||
|
||||
m.DisableWebPagePreview = b.GetBool("DisableWebPagePreview")
|
||||
|
||||
res, err := b.c.Send(m)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strconv.Itoa(res.MessageID), nil
|
||||
}
|
||||
|
||||
func (b *Btelegram) cacheAvatar(msg *config.Message) (string, error) {
|
||||
fi := msg.Extra["file"][0].(config.FileInfo)
|
||||
/* if we have a sha we have successfully uploaded the file to the media server,
|
||||
so we can now cache the sha */
|
||||
if fi.SHA != "" {
|
||||
b.Log.Debugf("Added %s to %s in avatarMap", fi.SHA, msg.UserID)
|
||||
b.avatarMap[msg.UserID] = fi.SHA
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
327
bridge/vk/vk.go
Normal file
327
bridge/vk/vk.go
Normal file
@@ -0,0 +1,327 @@
|
||||
package bvk
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/42wim/matterbridge/bridge"
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
"github.com/42wim/matterbridge/bridge/helper"
|
||||
|
||||
"github.com/SevereCloud/vksdk/v2/api"
|
||||
"github.com/SevereCloud/vksdk/v2/events"
|
||||
longpoll "github.com/SevereCloud/vksdk/v2/longpoll-bot"
|
||||
"github.com/SevereCloud/vksdk/v2/object"
|
||||
)
|
||||
|
||||
const (
|
||||
audioMessage = "audio_message"
|
||||
document = "doc"
|
||||
photo = "photo"
|
||||
video = "video"
|
||||
graffiti = "graffiti"
|
||||
sticker = "sticker"
|
||||
wall = "wall"
|
||||
)
|
||||
|
||||
type user struct {
|
||||
lastname, firstname, avatar string
|
||||
}
|
||||
|
||||
type Bvk struct {
|
||||
c *api.VK
|
||||
usernamesMap map[int]user // cache of user names and avatar URLs
|
||||
*bridge.Config
|
||||
}
|
||||
|
||||
func New(cfg *bridge.Config) bridge.Bridger {
|
||||
return &Bvk{usernamesMap: make(map[int]user), Config: cfg}
|
||||
}
|
||||
|
||||
func (b *Bvk) Connect() error {
|
||||
b.Log.Info("Connecting")
|
||||
b.c = api.NewVK(b.GetString("Token"))
|
||||
lp, err := longpoll.NewLongPoll(b.c, b.GetInt("GroupID"))
|
||||
if err != nil {
|
||||
b.Log.Debugf("%#v", err)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
lp.MessageNew(func(ctx context.Context, obj events.MessageNewObject) {
|
||||
b.handleMessage(obj.Message, false)
|
||||
})
|
||||
|
||||
b.Log.Info("Connection succeeded")
|
||||
|
||||
go func() {
|
||||
err := lp.Run()
|
||||
if err != nil {
|
||||
b.Log.Fatal("Enable longpoll in group management")
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bvk) Disconnect() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bvk) JoinChannel(channel config.ChannelInfo) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bvk) Send(msg config.Message) (string, error) {
|
||||
b.Log.Debugf("=> Receiving %#v", msg)
|
||||
|
||||
peerID, err := strconv.Atoi(msg.Channel)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
params := api.Params{}
|
||||
|
||||
text := msg.Username + msg.Text
|
||||
|
||||
if msg.Extra != nil {
|
||||
if len(msg.Extra["file"]) > 0 {
|
||||
// generate attachments string
|
||||
attachment, urls := b.uploadFiles(msg.Extra, peerID)
|
||||
params["attachment"] = attachment
|
||||
text += urls
|
||||
}
|
||||
}
|
||||
|
||||
params["message"] = text
|
||||
|
||||
if msg.ID == "" {
|
||||
// New message
|
||||
params["random_id"] = time.Now().Unix()
|
||||
params["peer_ids"] = msg.Channel
|
||||
|
||||
res, e := b.c.MessagesSendPeerIDs(params)
|
||||
if e != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return strconv.Itoa(res[0].ConversationMessageID), nil
|
||||
}
|
||||
// Edit message
|
||||
messageID, err := strconv.ParseInt(msg.ID, 10, 64)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
params["peer_id"] = peerID
|
||||
params["conversation_message_id"] = messageID
|
||||
|
||||
_, err = b.c.MessagesEdit(params)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return msg.ID, nil
|
||||
}
|
||||
|
||||
func (b *Bvk) getUser(id int) user {
|
||||
u, found := b.usernamesMap[id]
|
||||
if !found {
|
||||
b.Log.Debug("Fetching username for ", id)
|
||||
|
||||
if id >= 0 {
|
||||
result, _ := b.c.UsersGet(api.Params{
|
||||
"user_ids": id,
|
||||
"fields": "photo_200",
|
||||
})
|
||||
|
||||
resUser := result[0]
|
||||
u = user{lastname: resUser.LastName, firstname: resUser.FirstName, avatar: resUser.Photo200}
|
||||
b.usernamesMap[id] = u
|
||||
} else {
|
||||
result, _ := b.c.GroupsGetByID(api.Params{
|
||||
"group_id": id * -1,
|
||||
})
|
||||
|
||||
resGroup := result[0]
|
||||
u = user{lastname: resGroup.Name, avatar: resGroup.Photo200}
|
||||
}
|
||||
}
|
||||
|
||||
return u
|
||||
}
|
||||
|
||||
func (b *Bvk) handleMessage(msg object.MessagesMessage, isFwd bool) {
|
||||
b.Log.Debug("ChatID: ", msg.PeerID)
|
||||
// fetch user info
|
||||
u := b.getUser(msg.FromID)
|
||||
|
||||
rmsg := config.Message{
|
||||
Text: msg.Text,
|
||||
Username: u.firstname + " " + u.lastname,
|
||||
Avatar: u.avatar,
|
||||
Channel: strconv.Itoa(msg.PeerID),
|
||||
Account: b.Account,
|
||||
UserID: strconv.Itoa(msg.FromID),
|
||||
ID: strconv.Itoa(msg.ConversationMessageID),
|
||||
Extra: make(map[string][]interface{}),
|
||||
}
|
||||
|
||||
if msg.ReplyMessage != nil {
|
||||
ur := b.getUser(msg.ReplyMessage.FromID)
|
||||
rmsg.Text = "Re: " + ur.firstname + " " + ur.lastname + "\n" + rmsg.Text
|
||||
}
|
||||
|
||||
if isFwd {
|
||||
rmsg.Username = "Fwd: " + rmsg.Username
|
||||
}
|
||||
|
||||
if len(msg.Attachments) > 0 {
|
||||
urls, text := b.getFiles(msg.Attachments)
|
||||
|
||||
if text != "" {
|
||||
rmsg.Text += "\n" + text
|
||||
}
|
||||
|
||||
// download
|
||||
b.downloadFiles(&rmsg, urls)
|
||||
}
|
||||
|
||||
if len(msg.FwdMessages) > 0 {
|
||||
rmsg.Text += strconv.Itoa(len(msg.FwdMessages)) + " forwarded messages"
|
||||
}
|
||||
|
||||
b.Remote <- rmsg
|
||||
|
||||
if len(msg.FwdMessages) > 0 {
|
||||
// recursive processing of forwarded messages
|
||||
for _, m := range msg.FwdMessages {
|
||||
m.PeerID = msg.PeerID
|
||||
b.handleMessage(m, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bvk) uploadFiles(extra map[string][]interface{}, peerID int) (string, string) {
|
||||
var attachments []string
|
||||
text := ""
|
||||
|
||||
for _, f := range extra["file"] {
|
||||
fi := f.(config.FileInfo)
|
||||
|
||||
if fi.Comment != "" {
|
||||
text += fi.Comment + "\n"
|
||||
}
|
||||
a, err := b.uploadFile(fi, peerID)
|
||||
if err != nil {
|
||||
b.Log.Error("File upload error ", fi.Name)
|
||||
}
|
||||
|
||||
attachments = append(attachments, a)
|
||||
}
|
||||
|
||||
return strings.Join(attachments, ","), text
|
||||
}
|
||||
|
||||
func (b *Bvk) uploadFile(file config.FileInfo, peerID int) (string, error) {
|
||||
r := bytes.NewReader(*file.Data)
|
||||
|
||||
photoRE := regexp.MustCompile(".(jpg|jpe|png)$")
|
||||
if photoRE.MatchString(file.Name) {
|
||||
p, err := b.c.UploadMessagesPhoto(peerID, r)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return photo + strconv.Itoa(p[0].OwnerID) + "_" + strconv.Itoa(p[0].ID), nil
|
||||
}
|
||||
|
||||
var doctype string
|
||||
if strings.Contains(file.Name, ".ogg") {
|
||||
doctype = audioMessage
|
||||
} else {
|
||||
doctype = document
|
||||
}
|
||||
|
||||
doc, err := b.c.UploadMessagesDoc(peerID, doctype, file.Name, "", r)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
switch doc.Type {
|
||||
case audioMessage:
|
||||
return document + strconv.Itoa(doc.AudioMessage.OwnerID) + "_" + strconv.Itoa(doc.AudioMessage.ID), nil
|
||||
case document:
|
||||
return document + strconv.Itoa(doc.Doc.OwnerID) + "_" + strconv.Itoa(doc.Doc.ID), nil
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (b *Bvk) getFiles(attachments []object.MessagesMessageAttachment) ([]string, string) {
|
||||
var urls []string
|
||||
var text []string
|
||||
|
||||
for _, a := range attachments {
|
||||
switch a.Type {
|
||||
case photo:
|
||||
var resolution float64 = 0
|
||||
url := a.Photo.Sizes[0].URL
|
||||
for _, size := range a.Photo.Sizes {
|
||||
r := size.Height * size.Width
|
||||
if resolution < r {
|
||||
resolution = r
|
||||
url = size.URL
|
||||
}
|
||||
}
|
||||
|
||||
urls = append(urls, url)
|
||||
|
||||
case document:
|
||||
urls = append(urls, a.Doc.URL)
|
||||
|
||||
case graffiti:
|
||||
urls = append(urls, a.Graffiti.URL)
|
||||
|
||||
case audioMessage:
|
||||
urls = append(urls, a.AudioMessage.DocsDocPreviewAudioMessage.LinkOgg)
|
||||
|
||||
case sticker:
|
||||
var resolution float64 = 0
|
||||
url := a.Sticker.Images[0].URL
|
||||
for _, size := range a.Sticker.Images {
|
||||
r := size.Height * size.Width
|
||||
if resolution < r {
|
||||
resolution = r
|
||||
url = size.URL
|
||||
}
|
||||
}
|
||||
urls = append(urls, url+".png")
|
||||
case video:
|
||||
text = append(text, "https://vk.com/video"+strconv.Itoa(a.Video.OwnerID)+"_"+strconv.Itoa(a.Video.ID))
|
||||
|
||||
case wall:
|
||||
text = append(text, "https://vk.com/wall"+strconv.Itoa(a.Wall.FromID)+"_"+strconv.Itoa(a.Wall.ID))
|
||||
|
||||
default:
|
||||
text = append(text, "This attachment is not supported ("+a.Type+")")
|
||||
}
|
||||
}
|
||||
|
||||
return urls, strings.Join(text, "\n")
|
||||
}
|
||||
|
||||
func (b *Bvk) downloadFiles(rmsg *config.Message, urls []string) {
|
||||
for _, url := range urls {
|
||||
data, err := helper.DownloadFile(url)
|
||||
if err == nil {
|
||||
urlPart := strings.Split(url, "/")
|
||||
name := strings.Split(urlPart[len(urlPart)-1], "?")[0]
|
||||
helper.HandleDownloadData(b.Log, rmsg, name, "", url, data, b.General)
|
||||
}
|
||||
}
|
||||
}
|
||||
310
bridge/whatsapp/handlers.go
Normal file
310
bridge/whatsapp/handlers.go
Normal file
@@ -0,0 +1,310 @@
|
||||
package bwhatsapp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"mime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
"github.com/42wim/matterbridge/bridge/helper"
|
||||
"github.com/Rhymen/go-whatsapp"
|
||||
"github.com/jpillora/backoff"
|
||||
)
|
||||
|
||||
/*
|
||||
Implement handling messages coming from WhatsApp
|
||||
Check:
|
||||
- https://github.com/Rhymen/go-whatsapp#add-message-handlers
|
||||
- https://github.com/Rhymen/go-whatsapp/blob/master/handler.go
|
||||
- https://github.com/tulir/mautrix-whatsapp/tree/master/whatsapp-ext for more advanced command handling
|
||||
*/
|
||||
|
||||
// HandleError received from WhatsApp
|
||||
func (b *Bwhatsapp) HandleError(err error) {
|
||||
// ignore received invalid data errors. https://github.com/42wim/matterbridge/issues/843
|
||||
// ignore tag 174 errors. https://github.com/42wim/matterbridge/issues/1094
|
||||
if strings.Contains(err.Error(), "error processing data: received invalid data") ||
|
||||
strings.Contains(err.Error(), "invalid string with tag 174") {
|
||||
return
|
||||
}
|
||||
|
||||
switch err.(type) {
|
||||
case *whatsapp.ErrConnectionClosed, *whatsapp.ErrConnectionFailed:
|
||||
b.reconnect(err)
|
||||
default:
|
||||
switch err {
|
||||
case whatsapp.ErrConnectionTimeout:
|
||||
b.reconnect(err)
|
||||
default:
|
||||
b.Log.Errorf("%v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bwhatsapp) reconnect(err error) {
|
||||
bf := &backoff.Backoff{
|
||||
Min: time.Second,
|
||||
Max: 5 * time.Minute,
|
||||
Jitter: true,
|
||||
}
|
||||
|
||||
for {
|
||||
d := bf.Duration()
|
||||
|
||||
b.Log.Errorf("Connection failed, underlying error: %v", err)
|
||||
b.Log.Infof("Waiting %s...", d)
|
||||
|
||||
time.Sleep(d)
|
||||
|
||||
b.Log.Info("Reconnecting...")
|
||||
|
||||
err := b.conn.Restore()
|
||||
if err == nil {
|
||||
bf.Reset()
|
||||
b.startedAt = uint64(time.Now().Unix())
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTextMessage sent from WhatsApp, relay it to the brige
|
||||
func (b *Bwhatsapp) HandleTextMessage(message whatsapp.TextMessage) {
|
||||
if message.Info.FromMe {
|
||||
return
|
||||
}
|
||||
// whatsapp sends last messages to show context , cut them
|
||||
if message.Info.Timestamp < b.startedAt {
|
||||
return
|
||||
}
|
||||
|
||||
groupJID := message.Info.RemoteJid
|
||||
senderJID := message.Info.SenderJid
|
||||
|
||||
if len(senderJID) == 0 {
|
||||
if message.Info.Source != nil && message.Info.Source.Participant != nil {
|
||||
senderJID = *message.Info.Source.Participant
|
||||
}
|
||||
}
|
||||
|
||||
// translate sender's JID to the nicest username we can get
|
||||
senderName := b.getSenderName(senderJID)
|
||||
if senderName == "" {
|
||||
senderName = "Someone" // don't expose telephone number
|
||||
}
|
||||
|
||||
extText := message.Info.Source.Message.ExtendedTextMessage
|
||||
if extText != nil && extText.ContextInfo != nil && extText.ContextInfo.MentionedJid != nil {
|
||||
// handle user mentions
|
||||
for _, mentionedJID := range extText.ContextInfo.MentionedJid {
|
||||
numberAndSuffix := strings.SplitN(mentionedJID, "@", 2)
|
||||
|
||||
// mentions comes as telephone numbers and we don't want to expose it to other bridges
|
||||
// replace it with something more meaninful to others
|
||||
mention := b.getSenderNotify(numberAndSuffix[0] + "@s.whatsapp.net")
|
||||
if mention == "" {
|
||||
mention = "someone"
|
||||
}
|
||||
|
||||
message.Text = strings.Replace(message.Text, "@"+numberAndSuffix[0], "@"+mention, 1)
|
||||
}
|
||||
}
|
||||
|
||||
rmsg := config.Message{
|
||||
UserID: senderJID,
|
||||
Username: senderName,
|
||||
Text: message.Text,
|
||||
Channel: groupJID,
|
||||
Account: b.Account,
|
||||
Protocol: b.Protocol,
|
||||
Extra: make(map[string][]interface{}),
|
||||
// ParentID: TODO, // TODO handle thread replies // map from Info.QuotedMessageID string
|
||||
ID: message.Info.Id,
|
||||
}
|
||||
|
||||
if avatarURL, exists := b.userAvatars[senderJID]; exists {
|
||||
rmsg.Avatar = avatarURL
|
||||
}
|
||||
|
||||
b.Log.Debugf("<= Sending message from %s on %s to gateway", senderJID, b.Account)
|
||||
b.Log.Debugf("<= Message is %#v", rmsg)
|
||||
|
||||
b.Remote <- rmsg
|
||||
}
|
||||
|
||||
// HandleImageMessage sent from WhatsApp, relay it to the brige
|
||||
func (b *Bwhatsapp) HandleImageMessage(message whatsapp.ImageMessage) {
|
||||
if message.Info.FromMe || message.Info.Timestamp < b.startedAt {
|
||||
return
|
||||
}
|
||||
|
||||
senderJID := message.Info.SenderJid
|
||||
if len(message.Info.SenderJid) == 0 && message.Info.Source != nil && message.Info.Source.Participant != nil {
|
||||
senderJID = *message.Info.Source.Participant
|
||||
}
|
||||
|
||||
senderName := b.getSenderName(message.Info.SenderJid)
|
||||
if senderName == "" {
|
||||
senderName = "Someone" // don't expose telephone number
|
||||
}
|
||||
|
||||
rmsg := config.Message{
|
||||
UserID: senderJID,
|
||||
Username: senderName,
|
||||
Channel: message.Info.RemoteJid,
|
||||
Account: b.Account,
|
||||
Protocol: b.Protocol,
|
||||
Extra: make(map[string][]interface{}),
|
||||
ID: message.Info.Id,
|
||||
}
|
||||
|
||||
if avatarURL, exists := b.userAvatars[senderJID]; exists {
|
||||
rmsg.Avatar = avatarURL
|
||||
}
|
||||
|
||||
fileExt, err := mime.ExtensionsByType(message.Type)
|
||||
if err != nil {
|
||||
b.Log.Errorf("Mimetype detection error: %s", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// rename .jfif to .jpg https://github.com/42wim/matterbridge/issues/1292
|
||||
if fileExt[0] == ".jfif" {
|
||||
fileExt[0] = ".jpg"
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("%v%v", message.Info.Id, fileExt[0])
|
||||
|
||||
b.Log.Debugf("Trying to download %s with type %s", filename, message.Type)
|
||||
|
||||
data, err := message.Download()
|
||||
if err != nil {
|
||||
b.Log.Errorf("Download image failed: %s", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Move file to bridge storage
|
||||
helper.HandleDownloadData(b.Log, &rmsg, filename, message.Caption, "", &data, b.General)
|
||||
|
||||
b.Log.Debugf("<= Sending message from %s on %s to gateway", senderJID, b.Account)
|
||||
b.Log.Debugf("<= Message is %#v", rmsg)
|
||||
|
||||
b.Remote <- rmsg
|
||||
}
|
||||
|
||||
// HandleVideoMessage downloads video messages
|
||||
func (b *Bwhatsapp) HandleVideoMessage(message whatsapp.VideoMessage) {
|
||||
if message.Info.FromMe || message.Info.Timestamp < b.startedAt {
|
||||
return
|
||||
}
|
||||
|
||||
senderJID := message.Info.SenderJid
|
||||
if len(message.Info.SenderJid) == 0 && message.Info.Source != nil && message.Info.Source.Participant != nil {
|
||||
senderJID = *message.Info.Source.Participant
|
||||
}
|
||||
|
||||
senderName := b.getSenderName(message.Info.SenderJid)
|
||||
if senderName == "" {
|
||||
senderName = "Someone" // don't expose telephone number
|
||||
}
|
||||
|
||||
rmsg := config.Message{
|
||||
UserID: senderJID,
|
||||
Username: senderName,
|
||||
Channel: message.Info.RemoteJid,
|
||||
Account: b.Account,
|
||||
Protocol: b.Protocol,
|
||||
Extra: make(map[string][]interface{}),
|
||||
ID: message.Info.Id,
|
||||
}
|
||||
|
||||
if avatarURL, exists := b.userAvatars[senderJID]; exists {
|
||||
rmsg.Avatar = avatarURL
|
||||
}
|
||||
|
||||
fileExt, err := mime.ExtensionsByType(message.Type)
|
||||
if err != nil {
|
||||
b.Log.Errorf("Mimetype detection error: %s", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("%v%v", message.Info.Id, fileExt[0])
|
||||
|
||||
b.Log.Debugf("Trying to download %s with size %#v and type %s", filename, message.Length, message.Type)
|
||||
|
||||
data, err := message.Download()
|
||||
if err != nil {
|
||||
b.Log.Errorf("Download video failed: %s", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Move file to bridge storage
|
||||
helper.HandleDownloadData(b.Log, &rmsg, filename, message.Caption, "", &data, b.General)
|
||||
|
||||
b.Log.Debugf("<= Sending message from %s on %s to gateway", senderJID, b.Account)
|
||||
b.Log.Debugf("<= Message is %#v", rmsg)
|
||||
|
||||
b.Remote <- rmsg
|
||||
}
|
||||
|
||||
// HandleAudioMessage downloads audio messages
|
||||
func (b *Bwhatsapp) HandleAudioMessage(message whatsapp.AudioMessage) {
|
||||
if message.Info.FromMe || message.Info.Timestamp < b.startedAt {
|
||||
return
|
||||
}
|
||||
|
||||
senderJID := message.Info.SenderJid
|
||||
if len(message.Info.SenderJid) == 0 && message.Info.Source != nil && message.Info.Source.Participant != nil {
|
||||
senderJID = *message.Info.Source.Participant
|
||||
}
|
||||
|
||||
senderName := b.getSenderName(message.Info.SenderJid)
|
||||
if senderName == "" {
|
||||
senderName = "Someone" // don't expose telephone number
|
||||
}
|
||||
|
||||
rmsg := config.Message{
|
||||
UserID: senderJID,
|
||||
Username: senderName,
|
||||
Channel: message.Info.RemoteJid,
|
||||
Account: b.Account,
|
||||
Protocol: b.Protocol,
|
||||
Extra: make(map[string][]interface{}),
|
||||
ID: message.Info.Id,
|
||||
}
|
||||
|
||||
if avatarURL, exists := b.userAvatars[senderJID]; exists {
|
||||
rmsg.Avatar = avatarURL
|
||||
}
|
||||
|
||||
fileExt, err := mime.ExtensionsByType(message.Type)
|
||||
if err != nil {
|
||||
b.Log.Errorf("Mimetype detection error: %s", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("%v%v", message.Info.Id, fileExt[0])
|
||||
|
||||
b.Log.Debugf("Trying to download %s with size %#v and type %s", filename, message.Length, message.Type)
|
||||
|
||||
data, err := message.Download()
|
||||
if err != nil {
|
||||
b.Log.Errorf("Download audio failed: %s", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Move file to bridge storage
|
||||
helper.HandleDownloadData(b.Log, &rmsg, filename, "audio message", "", &data, b.General)
|
||||
|
||||
b.Log.Debugf("<= Sending message from %s on %s to gateway", senderJID, b.Account)
|
||||
b.Log.Debugf("<= Message is %#v", rmsg)
|
||||
|
||||
b.Remote <- rmsg
|
||||
}
|
||||
163
bridge/whatsapp/helpers.go
Normal file
163
bridge/whatsapp/helpers.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package bwhatsapp
|
||||
|
||||
import (
|
||||
"encoding/gob"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
qrcodeTerminal "github.com/Baozisoftware/qrcode-terminal-go"
|
||||
"github.com/Rhymen/go-whatsapp"
|
||||
)
|
||||
|
||||
type ProfilePicInfo struct {
|
||||
URL string `json:"eurl"`
|
||||
Tag string `json:"tag"`
|
||||
Status int16 `json:"status"`
|
||||
}
|
||||
|
||||
func qrFromTerminal(invert bool) chan string {
|
||||
qr := make(chan string)
|
||||
|
||||
go func() {
|
||||
terminal := qrcodeTerminal.New()
|
||||
|
||||
if invert {
|
||||
terminal = qrcodeTerminal.New2(qrcodeTerminal.ConsoleColors.BrightWhite, qrcodeTerminal.ConsoleColors.BrightBlack, qrcodeTerminal.QRCodeRecoveryLevels.Medium)
|
||||
}
|
||||
|
||||
terminal.Get(<-qr).Print()
|
||||
}()
|
||||
|
||||
return qr
|
||||
}
|
||||
|
||||
func (b *Bwhatsapp) readSession() (whatsapp.Session, error) {
|
||||
session := whatsapp.Session{}
|
||||
sessionFile := b.Config.GetString(sessionFile)
|
||||
|
||||
if sessionFile == "" {
|
||||
return session, errors.New("if you won't set SessionFile then you will need to scan QR code on every restart")
|
||||
}
|
||||
|
||||
file, err := os.Open(sessionFile)
|
||||
if err != nil {
|
||||
return session, err
|
||||
}
|
||||
|
||||
defer file.Close()
|
||||
|
||||
decoder := gob.NewDecoder(file)
|
||||
|
||||
return session, decoder.Decode(&session)
|
||||
}
|
||||
|
||||
func (b *Bwhatsapp) writeSession(session whatsapp.Session) error {
|
||||
sessionFile := b.Config.GetString(sessionFile)
|
||||
|
||||
if sessionFile == "" {
|
||||
// we already sent a warning while starting the bridge, so let's be quiet here
|
||||
return nil
|
||||
}
|
||||
|
||||
file, err := os.Create(sessionFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer file.Close()
|
||||
|
||||
encoder := gob.NewEncoder(file)
|
||||
|
||||
return encoder.Encode(session)
|
||||
}
|
||||
|
||||
func (b *Bwhatsapp) restoreSession() (*whatsapp.Session, error) {
|
||||
session, err := b.readSession()
|
||||
if err != nil {
|
||||
b.Log.Warn(err.Error())
|
||||
}
|
||||
|
||||
b.Log.Debugln("Restoring WhatsApp session..")
|
||||
|
||||
session, err = b.conn.RestoreWithSession(session)
|
||||
if err != nil {
|
||||
// restore session connection timed out (I couldn't get over it without logging in again)
|
||||
return nil, errors.New("failed to restore session: " + err.Error())
|
||||
}
|
||||
|
||||
b.Log.Debugln("Session restored successfully!")
|
||||
|
||||
return &session, nil
|
||||
}
|
||||
|
||||
func (b *Bwhatsapp) getSenderName(senderJid string) string {
|
||||
if sender, exists := b.users[senderJid]; exists {
|
||||
if sender.Name != "" {
|
||||
return sender.Name
|
||||
}
|
||||
// if user is not in phone contacts
|
||||
// it is the most obvious scenario unless you sync your phone contacts with some remote updated source
|
||||
// users can change it in their WhatsApp settings -> profile -> click on Avatar
|
||||
if sender.Notify != "" {
|
||||
return sender.Notify
|
||||
}
|
||||
|
||||
if sender.Short != "" {
|
||||
return sender.Short
|
||||
}
|
||||
}
|
||||
|
||||
// try to reload this contact
|
||||
_, err := b.conn.Contacts()
|
||||
if err != nil {
|
||||
b.Log.Errorf("error on update of contacts: %v", err)
|
||||
}
|
||||
|
||||
if contact, exists := b.conn.Store.Contacts[senderJid]; exists {
|
||||
// Add it to the user map
|
||||
b.users[senderJid] = contact
|
||||
|
||||
if contact.Name != "" {
|
||||
return contact.Name
|
||||
}
|
||||
// if user is not in phone contacts
|
||||
// same as above
|
||||
return contact.Notify
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func (b *Bwhatsapp) getSenderNotify(senderJid string) string {
|
||||
if sender, exists := b.users[senderJid]; exists {
|
||||
return sender.Notify
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func (b *Bwhatsapp) GetProfilePicThumb(jid string) (*ProfilePicInfo, error) {
|
||||
data, err := b.conn.GetProfilePicThumb(jid)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get avatar: %v", err)
|
||||
}
|
||||
|
||||
content := <-data
|
||||
info := &ProfilePicInfo{}
|
||||
|
||||
err = json.Unmarshal([]byte(content), info)
|
||||
if err != nil {
|
||||
return info, fmt.Errorf("failed to unmarshal avatar info: %v", err)
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func isGroupJid(identifier string) bool {
|
||||
return strings.HasSuffix(identifier, "@g.us") ||
|
||||
strings.HasSuffix(identifier, "@temp") ||
|
||||
strings.HasSuffix(identifier, "@broadcast")
|
||||
}
|
||||
332
bridge/whatsapp/whatsapp.go
Normal file
332
bridge/whatsapp/whatsapp.go
Normal file
@@ -0,0 +1,332 @@
|
||||
package bwhatsapp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/42wim/matterbridge/bridge"
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
"github.com/Rhymen/go-whatsapp"
|
||||
)
|
||||
|
||||
const (
|
||||
// Account config parameters
|
||||
cfgNumber = "Number"
|
||||
qrOnWhiteTerminal = "QrOnWhiteTerminal"
|
||||
sessionFile = "SessionFile"
|
||||
)
|
||||
|
||||
// Bwhatsapp Bridge structure keeping all the information needed for relying
|
||||
type Bwhatsapp struct {
|
||||
*bridge.Config
|
||||
|
||||
session *whatsapp.Session
|
||||
conn *whatsapp.Conn
|
||||
startedAt uint64
|
||||
|
||||
users map[string]whatsapp.Contact
|
||||
userAvatars map[string]string
|
||||
}
|
||||
|
||||
// New Create a new WhatsApp bridge. This will be called for each [whatsapp.<server>] entry you have in the config file
|
||||
func New(cfg *bridge.Config) bridge.Bridger {
|
||||
number := cfg.GetString(cfgNumber)
|
||||
|
||||
if number == "" {
|
||||
cfg.Log.Fatalf("Missing configuration for WhatsApp bridge: Number")
|
||||
}
|
||||
|
||||
b := &Bwhatsapp{
|
||||
Config: cfg,
|
||||
|
||||
users: make(map[string]whatsapp.Contact),
|
||||
userAvatars: make(map[string]string),
|
||||
}
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
// Connect to WhatsApp. Required implementation of the Bridger interface
|
||||
func (b *Bwhatsapp) Connect() error {
|
||||
number := b.GetString(cfgNumber)
|
||||
if number == "" {
|
||||
return errors.New("whatsapp's telephone number need to be configured")
|
||||
}
|
||||
|
||||
b.Log.Debugln("Connecting to WhatsApp..")
|
||||
conn, err := whatsapp.NewConn(20 * time.Second)
|
||||
if err != nil {
|
||||
return errors.New("failed to connect to WhatsApp: " + err.Error())
|
||||
}
|
||||
|
||||
b.conn = conn
|
||||
|
||||
b.conn.AddHandler(b)
|
||||
b.Log.Debugln("WhatsApp connection successful")
|
||||
|
||||
// load existing session in order to keep it between restarts
|
||||
b.session, err = b.restoreSession()
|
||||
if err != nil {
|
||||
b.Log.Warn(err.Error())
|
||||
}
|
||||
|
||||
// login to a new session
|
||||
if b.session == nil {
|
||||
if err = b.Login(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
b.startedAt = uint64(time.Now().Unix())
|
||||
|
||||
_, err = b.conn.Contacts()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error on update of contacts: %v", err)
|
||||
}
|
||||
|
||||
// see https://github.com/Rhymen/go-whatsapp/issues/137#issuecomment-480316013
|
||||
for len(b.conn.Store.Contacts) == 0 {
|
||||
b.conn.Contacts() // nolint:errcheck
|
||||
|
||||
<-time.After(1 * time.Second)
|
||||
}
|
||||
|
||||
// map all the users
|
||||
for id, contact := range b.conn.Store.Contacts {
|
||||
if !isGroupJid(id) && id != "status@broadcast" {
|
||||
// it is user
|
||||
b.users[id] = contact
|
||||
}
|
||||
}
|
||||
|
||||
// get user avatar asynchronously
|
||||
go func() {
|
||||
b.Log.Debug("Getting user avatars..")
|
||||
|
||||
for jid := range b.users {
|
||||
info, err := b.GetProfilePicThumb(jid)
|
||||
if err != nil {
|
||||
b.Log.Warnf("Could not get profile photo of %s: %v", jid, err)
|
||||
} else {
|
||||
b.Lock()
|
||||
b.userAvatars[jid] = info.URL
|
||||
b.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
b.Log.Debug("Finished getting avatars..")
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Login to WhatsApp creating a new session. This will require to scan a QR code on your mobile device
|
||||
func (b *Bwhatsapp) Login() error {
|
||||
b.Log.Debugln("Logging in..")
|
||||
|
||||
invert := b.GetBool(qrOnWhiteTerminal) // false is the default
|
||||
qrChan := qrFromTerminal(invert)
|
||||
|
||||
session, err := b.conn.Login(qrChan)
|
||||
if err != nil {
|
||||
b.Log.Warnln("Failed to log in:", err)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
b.session = &session
|
||||
|
||||
b.Log.Infof("Logged into session: %#v", session)
|
||||
b.Log.Infof("Connection: %#v", b.conn)
|
||||
|
||||
err = b.writeSession(session)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error saving session: %v\n", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Disconnect is called while reconnecting to the bridge
|
||||
// Required implementation of the Bridger interface
|
||||
func (b *Bwhatsapp) Disconnect() error {
|
||||
// We could Logout, but that would close the session completely and would require a new QR code scan
|
||||
// https://github.com/Rhymen/go-whatsapp/blob/c31092027237441cffba1b9cb148eadf7c83c3d2/session.go#L377-L381
|
||||
return nil
|
||||
}
|
||||
|
||||
// JoinChannel Join a WhatsApp group specified in gateway config as channel='number-id@g.us' or channel='Channel name'
|
||||
// Required implementation of the Bridger interface
|
||||
// https://github.com/42wim/matterbridge/blob/2cfd880cdb0df29771bf8f31df8d990ab897889d/bridge/bridge.go#L11-L16
|
||||
func (b *Bwhatsapp) JoinChannel(channel config.ChannelInfo) error {
|
||||
byJid := isGroupJid(channel.Name)
|
||||
|
||||
// see https://github.com/Rhymen/go-whatsapp/issues/137#issuecomment-480316013
|
||||
for len(b.conn.Store.Contacts) == 0 {
|
||||
b.conn.Contacts() // nolint:errcheck
|
||||
<-time.After(1 * time.Second)
|
||||
}
|
||||
|
||||
// verify if we are member of the given group
|
||||
if byJid {
|
||||
// channel.Name specifies static group jID, not the name
|
||||
if _, exists := b.conn.Store.Contacts[channel.Name]; !exists {
|
||||
return fmt.Errorf("account doesn't belong to group with jid %s", channel.Name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// channel.Name specifies group name that might change, warn about it
|
||||
var jids []string
|
||||
for id, contact := range b.conn.Store.Contacts {
|
||||
if isGroupJid(id) && contact.Name == channel.Name {
|
||||
jids = append(jids, id)
|
||||
}
|
||||
}
|
||||
|
||||
switch len(jids) {
|
||||
case 0:
|
||||
// didn't match any group - print out possibilites
|
||||
for id, contact := range b.conn.Store.Contacts {
|
||||
if isGroupJid(id) {
|
||||
b.Log.Infof("%s %s", contact.Jid, contact.Name)
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("please specify group's JID from the list above instead of the name '%s'", channel.Name)
|
||||
case 1:
|
||||
return fmt.Errorf("group name might change. Please configure gateway with channel=\"%v\" instead of channel=\"%v\"", jids[0], channel.Name)
|
||||
default:
|
||||
return fmt.Errorf("there is more than one group with name '%s'. Please specify one of JIDs as channel name: %v", channel.Name, jids)
|
||||
}
|
||||
}
|
||||
|
||||
// Post a document message from the bridge to WhatsApp
|
||||
func (b *Bwhatsapp) PostDocumentMessage(msg config.Message, filetype string) (string, error) {
|
||||
fi := msg.Extra["file"][0].(config.FileInfo)
|
||||
|
||||
// Post document message
|
||||
message := whatsapp.DocumentMessage{
|
||||
Info: whatsapp.MessageInfo{
|
||||
RemoteJid: msg.Channel,
|
||||
},
|
||||
Title: fi.Name,
|
||||
FileName: fi.Name,
|
||||
Type: filetype,
|
||||
Content: bytes.NewReader(*fi.Data),
|
||||
}
|
||||
|
||||
b.Log.Debugf("=> Sending %#v", msg)
|
||||
|
||||
// create message ID
|
||||
// TODO follow and act if https://github.com/Rhymen/go-whatsapp/issues/101 implemented
|
||||
idBytes := make([]byte, 10)
|
||||
if _, err := rand.Read(idBytes); err != nil {
|
||||
b.Log.Warn(err.Error())
|
||||
}
|
||||
|
||||
message.Info.Id = strings.ToUpper(hex.EncodeToString(idBytes))
|
||||
_, err := b.conn.Send(message)
|
||||
|
||||
return message.Info.Id, err
|
||||
}
|
||||
|
||||
// Post an image message from the bridge to WhatsApp
|
||||
// Handle, for sure image/jpeg, image/png and image/gif MIME types
|
||||
func (b *Bwhatsapp) PostImageMessage(msg config.Message, filetype string) (string, error) {
|
||||
fi := msg.Extra["file"][0].(config.FileInfo)
|
||||
|
||||
// Post image message
|
||||
message := whatsapp.ImageMessage{
|
||||
Info: whatsapp.MessageInfo{
|
||||
RemoteJid: msg.Channel,
|
||||
},
|
||||
Type: filetype,
|
||||
Caption: msg.Username + fi.Comment,
|
||||
Content: bytes.NewReader(*fi.Data),
|
||||
}
|
||||
|
||||
b.Log.Debugf("=> Sending %#v", msg)
|
||||
|
||||
// create message ID
|
||||
// TODO follow and act if https://github.com/Rhymen/go-whatsapp/issues/101 implemented
|
||||
idBytes := make([]byte, 10)
|
||||
if _, err := rand.Read(idBytes); err != nil {
|
||||
b.Log.Warn(err.Error())
|
||||
}
|
||||
|
||||
message.Info.Id = strings.ToUpper(hex.EncodeToString(idBytes))
|
||||
_, err := b.conn.Send(message)
|
||||
|
||||
return message.Info.Id, err
|
||||
}
|
||||
|
||||
// Send a message from the bridge to WhatsApp
|
||||
// Required implementation of the Bridger interface
|
||||
// https://github.com/42wim/matterbridge/blob/2cfd880cdb0df29771bf8f31df8d990ab897889d/bridge/bridge.go#L11-L16
|
||||
func (b *Bwhatsapp) Send(msg config.Message) (string, error) {
|
||||
b.Log.Debugf("=> Receiving %#v", msg)
|
||||
|
||||
// Delete message
|
||||
if msg.Event == config.EventMsgDelete {
|
||||
if msg.ID == "" {
|
||||
// No message ID in case action is executed on a message sent before the bridge was started
|
||||
// and then the bridge cache doesn't have this message ID mapped
|
||||
return "", nil
|
||||
}
|
||||
|
||||
_, err := b.conn.RevokeMessage(msg.Channel, msg.ID, true)
|
||||
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Edit message
|
||||
if msg.ID != "" {
|
||||
b.Log.Debugf("updating message with id %s", msg.ID)
|
||||
|
||||
msg.Text += " (edited)"
|
||||
}
|
||||
|
||||
// Handle Upload a file
|
||||
if msg.Extra["file"] != nil {
|
||||
fi := msg.Extra["file"][0].(config.FileInfo)
|
||||
filetype := mime.TypeByExtension(filepath.Ext(fi.Name))
|
||||
|
||||
b.Log.Debugf("Extra file is %#v", filetype)
|
||||
|
||||
// TODO: add different types
|
||||
// TODO: add webp conversion
|
||||
switch filetype {
|
||||
case "image/jpeg", "image/png", "image/gif":
|
||||
return b.PostImageMessage(msg, filetype)
|
||||
default:
|
||||
return b.PostDocumentMessage(msg, filetype)
|
||||
}
|
||||
}
|
||||
|
||||
// Post text message
|
||||
message := whatsapp.TextMessage{
|
||||
Info: whatsapp.MessageInfo{
|
||||
RemoteJid: msg.Channel, // which equals to group id
|
||||
},
|
||||
Text: msg.Username + msg.Text,
|
||||
}
|
||||
|
||||
b.Log.Debugf("=> Sending %#v", msg)
|
||||
|
||||
return b.conn.Send(message)
|
||||
}
|
||||
|
||||
// TODO do we want that? to allow login with QR code from a bridged channel? https://github.com/tulir/mautrix-whatsapp/blob/513eb18e2d59bada0dd515ee1abaaf38a3bfe3d5/commands.go#L76
|
||||
//func (b *Bwhatsapp) Command(cmd string) string {
|
||||
// return ""
|
||||
//}
|
||||
34
bridge/xmpp/handler.go
Normal file
34
bridge/xmpp/handler.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package bxmpp
|
||||
|
||||
import (
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
"github.com/42wim/matterbridge/bridge/helper"
|
||||
"github.com/matterbridge/go-xmpp"
|
||||
)
|
||||
|
||||
// handleDownloadAvatar downloads the avatar of userid from channel
|
||||
// sends a EVENT_AVATAR_DOWNLOAD message to the gateway if successful.
|
||||
// logs an error message if it fails
|
||||
func (b *Bxmpp) handleDownloadAvatar(avatar xmpp.AvatarData) {
|
||||
rmsg := config.Message{
|
||||
Username: "system",
|
||||
Text: "avatar",
|
||||
Channel: b.parseChannel(avatar.From),
|
||||
Account: b.Account,
|
||||
UserID: avatar.From,
|
||||
Event: config.EventAvatarDownload,
|
||||
Extra: make(map[string][]interface{}),
|
||||
}
|
||||
if _, ok := b.avatarMap[avatar.From]; !ok {
|
||||
b.Log.Debugf("Avatar.From: %s", avatar.From)
|
||||
|
||||
err := helper.HandleDownloadSize(b.Log, &rmsg, avatar.From+".png", int64(len(avatar.Data)), b.General)
|
||||
if err != nil {
|
||||
b.Log.Error(err)
|
||||
return
|
||||
}
|
||||
helper.HandleDownloadData(b.Log, &rmsg, avatar.From+".png", rmsg.Text, "", &avatar.Data, b.General)
|
||||
b.Log.Debugf("Avatar download complete")
|
||||
b.Remote <- rmsg
|
||||
}
|
||||
}
|
||||
30
bridge/xmpp/helpers.go
Normal file
30
bridge/xmpp/helpers.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package bxmpp
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
)
|
||||
|
||||
var pathRegex = regexp.MustCompile("[^a-zA-Z0-9]+")
|
||||
|
||||
// GetAvatar constructs a URL for a given user-avatar if it is available in the cache.
|
||||
func getAvatar(av map[string]string, userid string, general *config.Protocol) string {
|
||||
if hash, ok := av[userid]; ok {
|
||||
// NOTE: This does not happen in bridge/helper/helper.go but messes up XMPP
|
||||
id := pathRegex.ReplaceAllString(userid, "_")
|
||||
return general.MediaServerDownload + "/" + hash + "/" + id + ".png"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (b *Bxmpp) cacheAvatar(msg *config.Message) string {
|
||||
fi := msg.Extra["file"][0].(config.FileInfo)
|
||||
/* if we have a sha we have successfully uploaded the file to the media server,
|
||||
so we can now cache the sha */
|
||||
if fi.SHA != "" {
|
||||
b.Log.Debugf("Added %s to %s in avatarMap", fi.SHA, msg.UserID)
|
||||
b.avatarMap[msg.UserID] = fi.SHA
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,72 +1,54 @@
|
||||
package bxmpp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
log "github.com/Sirupsen/logrus"
|
||||
"github.com/jpillora/backoff"
|
||||
"github.com/mattn/go-xmpp"
|
||||
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/42wim/matterbridge/bridge"
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
"github.com/42wim/matterbridge/bridge/helper"
|
||||
"github.com/jpillora/backoff"
|
||||
"github.com/matterbridge/go-xmpp"
|
||||
"github.com/rs/xid"
|
||||
)
|
||||
|
||||
type Bxmpp struct {
|
||||
xc *xmpp.Client
|
||||
xmppMap map[string]string
|
||||
Config *config.Protocol
|
||||
Remote chan config.Message
|
||||
Account string
|
||||
*bridge.Config
|
||||
|
||||
startTime time.Time
|
||||
xc *xmpp.Client
|
||||
xmppMap map[string]string
|
||||
connected bool
|
||||
sync.RWMutex
|
||||
|
||||
avatarAvailability map[string]bool
|
||||
avatarMap map[string]string
|
||||
}
|
||||
|
||||
var flog *log.Entry
|
||||
var protocol = "xmpp"
|
||||
|
||||
func init() {
|
||||
flog = log.WithFields(log.Fields{"module": protocol})
|
||||
}
|
||||
|
||||
func New(cfg config.Protocol, account string, c chan config.Message) *Bxmpp {
|
||||
b := &Bxmpp{}
|
||||
b.xmppMap = make(map[string]string)
|
||||
b.Config = &cfg
|
||||
b.Account = account
|
||||
b.Remote = c
|
||||
return b
|
||||
func New(cfg *bridge.Config) bridge.Bridger {
|
||||
return &Bxmpp{
|
||||
Config: cfg,
|
||||
xmppMap: make(map[string]string),
|
||||
avatarAvailability: make(map[string]bool),
|
||||
avatarMap: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bxmpp) Connect() error {
|
||||
var err error
|
||||
flog.Infof("Connecting %s", b.Config.Server)
|
||||
b.xc, err = b.createXMPP()
|
||||
if err != nil {
|
||||
flog.Debugf("%#v", err)
|
||||
b.Log.Infof("Connecting %s", b.GetString("Server"))
|
||||
if err := b.createXMPP(); err != nil {
|
||||
b.Log.Debugf("%#v", err)
|
||||
return err
|
||||
}
|
||||
flog.Info("Connection succeeded")
|
||||
go func() {
|
||||
initial := true
|
||||
bf := &backoff.Backoff{
|
||||
Min: time.Second,
|
||||
Max: 5 * time.Minute,
|
||||
Jitter: true,
|
||||
}
|
||||
for {
|
||||
if initial {
|
||||
b.handleXmpp()
|
||||
initial = false
|
||||
}
|
||||
d := bf.Duration()
|
||||
flog.Infof("Disconnected. Reconnecting in %s", d)
|
||||
time.Sleep(d)
|
||||
b.xc, err = b.createXMPP()
|
||||
if err == nil {
|
||||
b.Remote <- config.Message{Username: "system", Text: "rejoin", Channel: "", Account: b.Account, Event: config.EVENT_REJOIN_CHANNELS}
|
||||
b.handleXmpp()
|
||||
bf.Reset()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
b.Log.Info("Connection succeeded")
|
||||
go b.manageConnection()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -75,44 +57,186 @@ func (b *Bxmpp) Disconnect() error {
|
||||
}
|
||||
|
||||
func (b *Bxmpp) JoinChannel(channel config.ChannelInfo) error {
|
||||
b.xc.JoinMUCNoHistory(channel.Name+"@"+b.Config.Muc, b.Config.Nick)
|
||||
if channel.Options.Key != "" {
|
||||
b.Log.Debugf("using key %s for channel %s", channel.Options.Key, channel.Name)
|
||||
b.xc.JoinProtectedMUC(channel.Name+"@"+b.GetString("Muc"), b.GetString("Nick"), channel.Options.Key, xmpp.NoHistory, 0, nil)
|
||||
} else {
|
||||
b.xc.JoinMUCNoHistory(channel.Name+"@"+b.GetString("Muc"), b.GetString("Nick"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bxmpp) Send(msg config.Message) (string, error) {
|
||||
// should be fixed by using a cache instead of dropping
|
||||
if !b.Connected() {
|
||||
return "", fmt.Errorf("bridge %s not connected, dropping message %#v to bridge", b.Account, msg)
|
||||
}
|
||||
// ignore delete messages
|
||||
if msg.Event == config.EVENT_MSG_DELETE {
|
||||
if msg.Event == config.EventMsgDelete {
|
||||
return "", nil
|
||||
}
|
||||
flog.Debugf("Receiving %#v", msg)
|
||||
b.xc.Send(xmpp.Chat{Type: "groupchat", Remote: msg.Channel + "@" + b.Config.Muc, Text: msg.Username + msg.Text})
|
||||
return "", nil
|
||||
|
||||
b.Log.Debugf("=> Receiving %#v", msg)
|
||||
|
||||
if msg.Event == config.EventAvatarDownload {
|
||||
return b.cacheAvatar(&msg), nil
|
||||
}
|
||||
|
||||
// Make a action /me of the message, prepend the username with it.
|
||||
// https://xmpp.org/extensions/xep-0245.html
|
||||
if msg.Event == config.EventUserAction {
|
||||
msg.Username = "/me " + msg.Username
|
||||
}
|
||||
|
||||
// Upload a file (in XMPP case send the upload URL because XMPP has no native upload support).
|
||||
var err error
|
||||
if msg.Extra != nil {
|
||||
for _, rmsg := range helper.HandleExtra(&msg, b.General) {
|
||||
b.Log.Debugf("=> Sending attachement message %#v", rmsg)
|
||||
if b.GetString("WebhookURL") != "" {
|
||||
err = b.postSlackCompatibleWebhook(msg)
|
||||
} else {
|
||||
_, err = b.xc.Send(xmpp.Chat{
|
||||
Type: "groupchat",
|
||||
Remote: rmsg.Channel + "@" + b.GetString("Muc"),
|
||||
Text: rmsg.Username + rmsg.Text,
|
||||
})
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
b.Log.WithError(err).Error("Unable to send message with share URL.")
|
||||
}
|
||||
}
|
||||
if len(msg.Extra["file"]) > 0 {
|
||||
return "", b.handleUploadFile(&msg)
|
||||
}
|
||||
}
|
||||
|
||||
if b.GetString("WebhookURL") != "" {
|
||||
b.Log.Debugf("Sending message using Webhook")
|
||||
err := b.postSlackCompatibleWebhook(msg)
|
||||
if err != nil {
|
||||
b.Log.Errorf("Failed to send message using webhook: %s", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Post normal message.
|
||||
var msgReplaceID string
|
||||
msgID := xid.New().String()
|
||||
if msg.ID != "" {
|
||||
msgID = msg.ID
|
||||
msgReplaceID = msg.ID
|
||||
}
|
||||
b.Log.Debugf("=> Sending message %#v", msg)
|
||||
if _, err := b.xc.Send(xmpp.Chat{
|
||||
Type: "groupchat",
|
||||
Remote: msg.Channel + "@" + b.GetString("Muc"),
|
||||
Text: msg.Username + msg.Text,
|
||||
ID: msgID,
|
||||
ReplaceID: msgReplaceID,
|
||||
}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return msgID, nil
|
||||
}
|
||||
|
||||
func (b *Bxmpp) createXMPP() (*xmpp.Client, error) {
|
||||
tc := new(tls.Config)
|
||||
tc.InsecureSkipVerify = b.Config.SkipTLSVerify
|
||||
tc.ServerName = strings.Split(b.Config.Server, ":")[0]
|
||||
options := xmpp.Options{
|
||||
Host: b.Config.Server,
|
||||
User: b.Config.Jid,
|
||||
Password: b.Config.Password,
|
||||
NoTLS: true,
|
||||
StartTLS: true,
|
||||
TLSConfig: tc,
|
||||
func (b *Bxmpp) postSlackCompatibleWebhook(msg config.Message) error {
|
||||
type XMPPWebhook struct {
|
||||
Username string `json:"username"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
webhookBody, err := json.Marshal(XMPPWebhook{
|
||||
Username: msg.Username,
|
||||
Text: msg.Text,
|
||||
})
|
||||
if err != nil {
|
||||
b.Log.Errorf("Failed to marshal webhook: %s", err)
|
||||
return err
|
||||
}
|
||||
|
||||
//StartTLS: false,
|
||||
Debug: true,
|
||||
resp, err := http.Post(b.GetString("WebhookURL")+"/"+msg.Channel, "application/json", bytes.NewReader(webhookBody))
|
||||
resp.Body.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
func (b *Bxmpp) createXMPP() error {
|
||||
if !strings.Contains(b.GetString("Jid"), "@") {
|
||||
return fmt.Errorf("the Jid %s doesn't contain an @", b.GetString("Jid"))
|
||||
}
|
||||
tc := &tls.Config{
|
||||
ServerName: strings.Split(b.GetString("Jid"), "@")[1],
|
||||
InsecureSkipVerify: b.GetBool("SkipTLSVerify"), // nolint: gosec
|
||||
}
|
||||
|
||||
xmpp.DebugWriter = b.Log.Writer()
|
||||
|
||||
options := xmpp.Options{
|
||||
Host: b.GetString("Server"),
|
||||
User: b.GetString("Jid"),
|
||||
Password: b.GetString("Password"),
|
||||
NoTLS: true,
|
||||
StartTLS: !b.GetBool("NoTLS"),
|
||||
TLSConfig: tc,
|
||||
Debug: b.GetBool("debug"),
|
||||
Session: true,
|
||||
Status: "",
|
||||
StatusMessage: "",
|
||||
Resource: "",
|
||||
InsecureAllowUnencryptedAuth: false,
|
||||
//InsecureAllowUnencryptedAuth: true,
|
||||
InsecureAllowUnencryptedAuth: b.GetBool("NoTLS"),
|
||||
}
|
||||
var err error
|
||||
b.xc, err = options.NewClient()
|
||||
return b.xc, err
|
||||
return err
|
||||
}
|
||||
|
||||
func (b *Bxmpp) manageConnection() {
|
||||
b.setConnected(true)
|
||||
initial := true
|
||||
bf := &backoff.Backoff{
|
||||
Min: time.Second,
|
||||
Max: 5 * time.Minute,
|
||||
Jitter: true,
|
||||
}
|
||||
|
||||
// Main connection loop. Each iteration corresponds to a successful
|
||||
// connection attempt and the subsequent handling of the connection.
|
||||
for {
|
||||
if initial {
|
||||
initial = false
|
||||
} else {
|
||||
b.Remote <- config.Message{
|
||||
Username: "system",
|
||||
Text: "rejoin",
|
||||
Channel: "",
|
||||
Account: b.Account,
|
||||
Event: config.EventRejoinChannels,
|
||||
}
|
||||
}
|
||||
|
||||
if err := b.handleXMPP(); err != nil {
|
||||
b.Log.WithError(err).Error("Disconnected.")
|
||||
b.setConnected(false)
|
||||
}
|
||||
|
||||
// Reconnection loop using an exponential back-off strategy. We
|
||||
// only break out of the loop if we have successfully reconnected.
|
||||
for {
|
||||
d := bf.Duration()
|
||||
b.Log.Infof("Reconnecting in %s.", d)
|
||||
time.Sleep(d)
|
||||
|
||||
b.Log.Infof("Reconnecting now.")
|
||||
if err := b.createXMPP(); err == nil {
|
||||
b.setConnected(true)
|
||||
bf.Reset()
|
||||
break
|
||||
}
|
||||
b.Log.Warn("Failed to reconnect.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bxmpp) xmppKeepAlive() chan bool {
|
||||
@@ -123,10 +247,9 @@ func (b *Bxmpp) xmppKeepAlive() chan bool {
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
flog.Debugf("PING")
|
||||
err := b.xc.PingC2S("", "")
|
||||
if err != nil {
|
||||
flog.Debugf("PING failed %#v", err)
|
||||
b.Log.Debugf("PING")
|
||||
if err := b.xc.PingC2S("", ""); err != nil {
|
||||
b.Log.Debugf("PING failed %#v", err)
|
||||
}
|
||||
case <-done:
|
||||
return
|
||||
@@ -136,40 +259,75 @@ func (b *Bxmpp) xmppKeepAlive() chan bool {
|
||||
return done
|
||||
}
|
||||
|
||||
func (b *Bxmpp) handleXmpp() error {
|
||||
var ok bool
|
||||
func (b *Bxmpp) handleXMPP() error {
|
||||
b.startTime = time.Now()
|
||||
|
||||
done := b.xmppKeepAlive()
|
||||
defer close(done)
|
||||
nodelay := time.Time{}
|
||||
|
||||
for {
|
||||
m, err := b.xc.Recv()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch v := m.(type) {
|
||||
case xmpp.Chat:
|
||||
var channel, nick string
|
||||
if v.Type == "groupchat" {
|
||||
s := strings.Split(v.Remote, "@")
|
||||
if len(s) >= 2 {
|
||||
channel = s[0]
|
||||
b.Log.Debugf("== Receiving %#v", v)
|
||||
|
||||
// Skip invalid messages.
|
||||
if b.skipMessage(v) {
|
||||
continue
|
||||
}
|
||||
s = strings.Split(s[1], "/")
|
||||
if len(s) == 2 {
|
||||
nick = s[1]
|
||||
|
||||
var event string
|
||||
if strings.Contains(v.Text, "has set the subject to:") {
|
||||
event = config.EventTopicChange
|
||||
}
|
||||
if nick != b.Config.Nick && v.Stamp == nodelay && v.Text != "" {
|
||||
rmsg := config.Message{Username: nick, Text: v.Text, Channel: channel, Account: b.Account, UserID: v.Remote}
|
||||
rmsg.Text, ok = b.replaceAction(rmsg.Text)
|
||||
if ok {
|
||||
rmsg.Event = config.EVENT_USER_ACTION
|
||||
}
|
||||
flog.Debugf("Sending message from %s on %s to gateway", nick, b.Account)
|
||||
b.Remote <- rmsg
|
||||
|
||||
available, sok := b.avatarAvailability[v.Remote]
|
||||
avatar := ""
|
||||
if !sok {
|
||||
b.Log.Debugf("Requesting avatar data")
|
||||
b.avatarAvailability[v.Remote] = false
|
||||
b.xc.AvatarRequestData(v.Remote)
|
||||
} else if available {
|
||||
avatar = getAvatar(b.avatarMap, v.Remote, b.General)
|
||||
}
|
||||
|
||||
msgID := v.ID
|
||||
if v.ReplaceID != "" {
|
||||
msgID = v.ReplaceID
|
||||
}
|
||||
rmsg := config.Message{
|
||||
Username: b.parseNick(v.Remote),
|
||||
Text: v.Text,
|
||||
Channel: b.parseChannel(v.Remote),
|
||||
Account: b.Account,
|
||||
Avatar: avatar,
|
||||
UserID: v.Remote,
|
||||
ID: msgID,
|
||||
Event: event,
|
||||
}
|
||||
|
||||
// Check if we have an action event.
|
||||
var ok bool
|
||||
rmsg.Text, ok = b.replaceAction(rmsg.Text)
|
||||
if ok {
|
||||
rmsg.Event = config.EventUserAction
|
||||
}
|
||||
|
||||
b.Log.Debugf("<= Sending message from %s on %s to gateway", rmsg.Username, b.Account)
|
||||
b.Log.Debugf("<= Message is %#v", rmsg)
|
||||
b.Remote <- rmsg
|
||||
}
|
||||
case xmpp.AvatarData:
|
||||
b.handleDownloadAvatar(v)
|
||||
b.avatarAvailability[v.From] = true
|
||||
b.Log.Debugf("Avatar for %s is now available", v.From)
|
||||
case xmpp.Presence:
|
||||
// do nothing
|
||||
// Do nothing.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -180,3 +338,103 @@ func (b *Bxmpp) replaceAction(text string) (string, bool) {
|
||||
}
|
||||
return text, false
|
||||
}
|
||||
|
||||
// handleUploadFile handles native upload of files
|
||||
func (b *Bxmpp) handleUploadFile(msg *config.Message) error {
|
||||
var urlDesc string
|
||||
|
||||
for _, file := range msg.Extra["file"] {
|
||||
fileInfo := file.(config.FileInfo)
|
||||
if fileInfo.Comment != "" {
|
||||
msg.Text += fileInfo.Comment + ": "
|
||||
}
|
||||
if fileInfo.URL != "" {
|
||||
msg.Text = fileInfo.URL
|
||||
if fileInfo.Comment != "" {
|
||||
msg.Text = fileInfo.Comment + ": " + fileInfo.URL
|
||||
urlDesc = fileInfo.Comment
|
||||
}
|
||||
}
|
||||
if _, err := b.xc.Send(xmpp.Chat{
|
||||
Type: "groupchat",
|
||||
Remote: msg.Channel + "@" + b.GetString("Muc"),
|
||||
Text: msg.Username + msg.Text,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if fileInfo.URL != "" {
|
||||
if _, err := b.xc.SendOOB(xmpp.Chat{
|
||||
Type: "groupchat",
|
||||
Remote: msg.Channel + "@" + b.GetString("Muc"),
|
||||
Ooburl: fileInfo.URL,
|
||||
Oobdesc: urlDesc,
|
||||
}); err != nil {
|
||||
b.Log.WithError(err).Warn("Failed to send share URL.")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bxmpp) parseNick(remote string) string {
|
||||
s := strings.Split(remote, "@")
|
||||
if len(s) > 0 {
|
||||
s = strings.Split(s[1], "/")
|
||||
if len(s) == 2 {
|
||||
return s[1] // nick
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (b *Bxmpp) parseChannel(remote string) string {
|
||||
s := strings.Split(remote, "@")
|
||||
if len(s) >= 2 {
|
||||
return s[0] // channel
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// skipMessage skips messages that need to be skipped
|
||||
func (b *Bxmpp) skipMessage(message xmpp.Chat) bool {
|
||||
// skip messages from ourselves
|
||||
if b.parseNick(message.Remote) == b.GetString("Nick") {
|
||||
return true
|
||||
}
|
||||
|
||||
// skip empty messages
|
||||
if message.Text == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
// skip subject messages
|
||||
if strings.Contains(message.Text, "</subject>") {
|
||||
return true
|
||||
}
|
||||
|
||||
// do not show subjects on connect #732
|
||||
if strings.Contains(message.Text, "has set the subject to:") && time.Since(b.startTime) < time.Second*5 {
|
||||
return true
|
||||
}
|
||||
|
||||
// Ignore messages posted by our webhook
|
||||
if b.GetString("WebhookURL") != "" && strings.Contains(message.ID, "webhookbot") {
|
||||
return true
|
||||
}
|
||||
|
||||
// skip delayed messages
|
||||
return !message.Stamp.IsZero() && time.Since(message.Stamp).Minutes() > 5
|
||||
}
|
||||
|
||||
func (b *Bxmpp) setConnected(state bool) {
|
||||
b.Lock()
|
||||
b.connected = state
|
||||
defer b.Unlock()
|
||||
}
|
||||
|
||||
func (b *Bxmpp) Connected() bool {
|
||||
b.RLock()
|
||||
defer b.RUnlock()
|
||||
return b.connected
|
||||
}
|
||||
|
||||
213
bridge/zulip/zulip.go
Normal file
213
bridge/zulip/zulip.go
Normal file
@@ -0,0 +1,213 @@
|
||||
package bzulip
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io/ioutil"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/42wim/matterbridge/bridge"
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
"github.com/42wim/matterbridge/bridge/helper"
|
||||
gzb "github.com/matterbridge/gozulipbot"
|
||||
)
|
||||
|
||||
type Bzulip struct {
|
||||
q *gzb.Queue
|
||||
bot *gzb.Bot
|
||||
streams map[int]string
|
||||
*bridge.Config
|
||||
sync.RWMutex
|
||||
}
|
||||
|
||||
func New(cfg *bridge.Config) bridge.Bridger {
|
||||
return &Bzulip{Config: cfg, streams: make(map[int]string)}
|
||||
}
|
||||
|
||||
func (b *Bzulip) Connect() error {
|
||||
bot := gzb.Bot{APIKey: b.GetString("token"), APIURL: b.GetString("server") + "/api/v1/", Email: b.GetString("login")}
|
||||
bot.Init()
|
||||
q, err := bot.RegisterAll()
|
||||
b.q = q
|
||||
b.bot = &bot
|
||||
if err != nil {
|
||||
b.Log.Errorf("Connect() %#v", err)
|
||||
return err
|
||||
}
|
||||
// init stream
|
||||
b.getChannel(0)
|
||||
b.Log.Info("Connection succeeded")
|
||||
go b.handleQueue()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bzulip) Disconnect() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bzulip) JoinChannel(channel config.ChannelInfo) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bzulip) Send(msg config.Message) (string, error) {
|
||||
b.Log.Debugf("=> Receiving %#v", msg)
|
||||
|
||||
// Delete message
|
||||
if msg.Event == config.EventMsgDelete {
|
||||
if msg.ID == "" {
|
||||
return "", nil
|
||||
}
|
||||
_, err := b.bot.UpdateMessage(msg.ID, "")
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Upload a file if it exists
|
||||
if msg.Extra != nil {
|
||||
for _, rmsg := range helper.HandleExtra(&msg, b.General) {
|
||||
b.sendMessage(rmsg)
|
||||
}
|
||||
if len(msg.Extra["file"]) > 0 {
|
||||
return b.handleUploadFile(&msg)
|
||||
}
|
||||
}
|
||||
|
||||
// edit the message if we have a msg ID
|
||||
if msg.ID != "" {
|
||||
_, err := b.bot.UpdateMessage(msg.ID, msg.Username+msg.Text)
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Post normal message
|
||||
return b.sendMessage(msg)
|
||||
}
|
||||
|
||||
func (b *Bzulip) getChannel(id int) string {
|
||||
if name, ok := b.streams[id]; ok {
|
||||
return name
|
||||
}
|
||||
streams, err := b.bot.GetRawStreams()
|
||||
if err != nil {
|
||||
b.Log.Errorf("getChannel: %#v", err)
|
||||
return ""
|
||||
}
|
||||
for _, stream := range streams.Streams {
|
||||
b.streams[stream.StreamID] = stream.Name
|
||||
}
|
||||
if name, ok := b.streams[id]; ok {
|
||||
return name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (b *Bzulip) handleQueue() error {
|
||||
for {
|
||||
messages, err := b.q.GetEvents()
|
||||
switch err {
|
||||
case gzb.BackoffError:
|
||||
time.Sleep(time.Second * 5)
|
||||
case gzb.NoJSONError:
|
||||
b.Log.Error("Response wasn't JSON, server down or restarting? sleeping 10 seconds")
|
||||
time.Sleep(time.Second * 10)
|
||||
case gzb.BadEventQueueError:
|
||||
b.Log.Info("got a bad event queue id error, reconnecting")
|
||||
b.bot.Queues = nil
|
||||
for {
|
||||
b.q, err = b.bot.RegisterAll()
|
||||
if err != nil {
|
||||
b.Log.Errorf("reconnecting failed: %s. Sleeping 10 seconds", err)
|
||||
time.Sleep(time.Second * 10)
|
||||
}
|
||||
break
|
||||
}
|
||||
case gzb.HeartbeatError:
|
||||
b.Log.Debug("heartbeat received.")
|
||||
default:
|
||||
b.Log.Debugf("receiving error: %#v", err)
|
||||
}
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, m := range messages {
|
||||
b.Log.Debugf("== Receiving %#v", m)
|
||||
// ignore our own messages
|
||||
if m.SenderEmail == b.GetString("login") {
|
||||
continue
|
||||
}
|
||||
|
||||
avatarURL := m.AvatarURL
|
||||
if !strings.HasPrefix(avatarURL, "http") {
|
||||
avatarURL = b.GetString("server") + avatarURL
|
||||
}
|
||||
|
||||
rmsg := config.Message{
|
||||
Username: m.SenderFullName,
|
||||
Text: m.Content,
|
||||
Channel: b.getChannel(m.StreamID) + "/topic:" + m.Subject,
|
||||
Account: b.Account,
|
||||
UserID: strconv.Itoa(m.SenderID),
|
||||
Avatar: avatarURL,
|
||||
}
|
||||
b.Log.Debugf("<= Sending message from %s on %s to gateway", rmsg.Username, b.Account)
|
||||
b.Log.Debugf("<= Message is %#v", rmsg)
|
||||
b.Remote <- rmsg
|
||||
}
|
||||
|
||||
time.Sleep(time.Second * 3)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bzulip) sendMessage(msg config.Message) (string, error) {
|
||||
topic := ""
|
||||
if strings.Contains(msg.Channel, "/topic:") {
|
||||
res := strings.Split(msg.Channel, "/topic:")
|
||||
topic = res[1]
|
||||
msg.Channel = res[0]
|
||||
}
|
||||
m := gzb.Message{
|
||||
Stream: msg.Channel,
|
||||
Topic: topic,
|
||||
Content: msg.Username + msg.Text,
|
||||
}
|
||||
resp, err := b.bot.Message(m)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resp != nil {
|
||||
defer resp.Body.Close()
|
||||
res, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var jr struct {
|
||||
ID int `json:"id"`
|
||||
}
|
||||
err = json.Unmarshal(res, &jr)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strconv.Itoa(jr.ID), nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (b *Bzulip) handleUploadFile(msg *config.Message) (string, error) {
|
||||
for _, f := range msg.Extra["file"] {
|
||||
fi := f.(config.FileInfo)
|
||||
if fi.Comment != "" {
|
||||
msg.Text += fi.Comment + ": "
|
||||
}
|
||||
if fi.URL != "" {
|
||||
msg.Text = fi.URL
|
||||
if fi.Comment != "" {
|
||||
msg.Text = fi.Comment + ": " + fi.URL
|
||||
}
|
||||
}
|
||||
_, err := b.sendMessage(*msg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
852
changelog.md
852
changelog.md
@@ -1,3 +1,855 @@
|
||||
# v1.22.0
|
||||
|
||||
Discord users using autowebhooks are encouraged to upgrade to this release.
|
||||
|
||||
## New features
|
||||
|
||||
- vk: new protocol added: Add vk support (#1245)
|
||||
- xmpp: Allow the XMPP bridge to use slack compatible webhooks (xmpp) (#1364)
|
||||
|
||||
## Enhancements
|
||||
|
||||
- telegram: Rename .oga audio files to .ogg (telegram) (#1349)
|
||||
- telegram: Add jpe as valid image filename extension (telegram) (#1360)
|
||||
- discord: Add an even more debug option (discord) (#1368)
|
||||
- general: Update vendor (#1384)
|
||||
|
||||
## Bugfixes
|
||||
|
||||
- discord: Pick up all the webhooks (discord) (#1383). Fixes #1353
|
||||
|
||||
This release couldn't exist without the following contributors:
|
||||
@ivanik7, @Polynomdivision, @PeterDaveHello, @Humorhenker, @qaisjp
|
||||
|
||||
# v1.21.0
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
- discord: Remove WebhookURL support (discord) (#1323)
|
||||
|
||||
`WebhookURL` global setting for discord is removed and will quit matterbridge.
|
||||
New `AutoWebhooks=true` setting, which will automatically use (and create, if they do not exist) webhooks inside specific channels. This only works if the bot has Manage Webhooks permission in bridged channels (global permission or as a channel permission override). Backwards compatibility with channel-specific webhooks. More info [here](https://github.com/42wim/matterbridge/blob/master/matterbridge.toml.sample#L862).
|
||||
|
||||
## New features
|
||||
|
||||
- discord: Create webhooks automatically (#1323)
|
||||
- discord: Add threading support with token (discord) (#1342)
|
||||
- irc: Join on invite (irc). Fixes #1231 (#1306)
|
||||
- irc: Add support for stateless bridging via draft/relaymsg (irc) (#1339)
|
||||
- whatsapp: Add support for deleting messages (whatsapp) (#1316)
|
||||
- whatsapp: Handle video downloads (whatsapp) (#1316)
|
||||
- whatsapp: Handle audio downloads (whatsapp) (#1316)
|
||||
|
||||
## Enhancements
|
||||
|
||||
- general: Parse fencedcode in ParseMarkdown. Fixes #1127 (#1329)
|
||||
- discord: Refactor guild finding code (discord) (#1319)
|
||||
- discord: Add a prefix handler for unthreaded messages (discord) (#1346)
|
||||
- irc: Add support for irc to irc notice (irc). Fixes #754 (#1305)
|
||||
- irc: Make handlers run async (irc) (#1325)
|
||||
- matrix: Show mxids in case of clashing usernames (matrix) (#1309)
|
||||
- matrix: Implement ratelimiting (matrix). Fixes #1238 (#1326)
|
||||
- matrix: Mark messages as read (matrix). Fixes #1317 (#1328)
|
||||
- nctalk: Update go-nc-talk (nctalk) (#1333)
|
||||
- rocketchat: Update rocketchat vendor (#1327)
|
||||
- tengo: Add UserID to RemoteNickFormat and Tengo (#1308)
|
||||
- whatsapp: Retry until we have contacts (whatsapp). Fixes #1122 (#1304)
|
||||
- whatsapp: Refactor/cleanup code (whatsapp)
|
||||
- whatsapp: Refactor handleTextMessage (whatsapp)
|
||||
- whatsapp: Refactor image downloads (whatsapp)
|
||||
- whatsapp: Rename jfif to jpg (whatsapp). Fixes #1292
|
||||
|
||||
## Bugfix
|
||||
|
||||
- discord: Reject cross-channel message references (discord) (#1345)
|
||||
- mumble: Add nil checks to text message handling (mumble) (#1321)
|
||||
|
||||
This release couldn't exist without the following contributors:
|
||||
@nightmared, @qaisjp, @jlu5, @wschwab, @gary-kim, @s3lph, @JeremyRand
|
||||
|
||||
# v1.20.0
|
||||
|
||||
## Breaking
|
||||
|
||||
- matrix: Send the display name instead of the user name (matrix) (#1282)
|
||||
Matrix now sends the displayname if set instead of the username. If you want to keep the username, add `UseUsername=true` to your matrix config. <https://github.com/42wim/matterbridge/wiki/Settings#useusername-1>
|
||||
- discord: Disable webhook editing (discord) (#1296)
|
||||
Because of issues with ratelimiting of webhook editing, this feature is now disabled. If you have multiple discord channels you bridge, you'll need to add a `webhookURL` to the `[gateway.inout.options]`. See <https://github.com/42wim/matterbridge/blob/master/matterbridge.toml.sample#L1864-L1870> for an example.
|
||||
|
||||
## New features
|
||||
|
||||
- general: Allow tengo to drop messages using msgDrop (#1272)
|
||||
- general: Update libraries (whatsapp,markdown,mattermost,ssh-chat)
|
||||
- irc: Add PingDelay option (irc) (#1269)
|
||||
- matrix: Allow message edits on matrix (#1286)
|
||||
- xmpp: add NoTLS option to allow plaintext XMPP connections (#1288)
|
||||
|
||||
## Enhancements
|
||||
|
||||
- discord: Edit messages via webhook (1287)
|
||||
- general: Add extra debug to log time spent sending a message per bridge (#1299)
|
||||
|
||||
This release couldn't exist without the following contributors:
|
||||
@nightmared, @zhoreeq
|
||||
|
||||
# v1.19.0
|
||||
|
||||
## New features
|
||||
|
||||
- mumble: new protocol added: Add Mumble support (#1245)
|
||||
- nctalk: Add support for downloading files (nctalk) (#1249)
|
||||
- nctalk: Append a suffix if user is a guest user (nctalk) (#1250)
|
||||
|
||||
## Enhancements
|
||||
|
||||
- irc: Add even more debug for irc (#1266)
|
||||
- matrix: Add username formatting for all events (matrix) (#1233)
|
||||
- matrix: Permit uploading files of other mimetypes (#1237)
|
||||
- whatsapp: Use vendored whatsapp version (#1258)
|
||||
- whatsapp: Add username for images from WhatsApp (#1232)
|
||||
|
||||
This release couldn't exist without the following contributors:
|
||||
@Dellle, @42wim, @gary-kim, @s3lph, @BenWiederhake
|
||||
|
||||
# v1.18.3
|
||||
|
||||
## Enhancements
|
||||
|
||||
- nctalk: Add TLSConfig to nctalk (#1195)
|
||||
- whatsapp: Handle broadcasts as groups in Whatsapp #1213
|
||||
- matrix: switch to upstream gomatrix #1219
|
||||
- api: support multiple websocket clients #1205
|
||||
|
||||
## Bugfix
|
||||
|
||||
- general: update vendor
|
||||
- zulip: Check location of avatarURL (zulip). Fixes #1214 (#1227)
|
||||
- nctalk: Fix issue with too many open files #1223
|
||||
- nctalk: Fix mentions #1222
|
||||
- nctalk: Fix message replays #1220
|
||||
|
||||
This release couldn't exist without the following contributors:
|
||||
@gary-kim, @tilosp, @NikkyAI, @escoand, @42wim
|
||||
|
||||
# v1.18.2
|
||||
|
||||
## Bugfix
|
||||
|
||||
- zulip: Fix error loop (zulip) (#1210)
|
||||
- whatsapp: Update whatsapp vendor and fix a panic (#1209)
|
||||
|
||||
This release couldn't exist without the following contributors:
|
||||
@SuperSandro2000, @42wim
|
||||
|
||||
# v1.18.1
|
||||
|
||||
## New features
|
||||
|
||||
- telegram: Support Telegram animated stickers (tgs) format (#1173). See https://github.com/42wim/matterbridge/wiki/Settings#mediaConverttgs for more info
|
||||
|
||||
## Enhancements
|
||||
|
||||
- matrix: Remove HTML formatting for push messages (#1188) (#1189)
|
||||
- mattermost: Use mattermost v5 module (#1192)
|
||||
|
||||
## Bugfix
|
||||
|
||||
- whatsapp: Handle panic in whatsapp. Fixes #1180 (#1184)
|
||||
- nctalk: Fix Nextcloud Talk connection failure (#1179)
|
||||
- matrix: Sleep when ratelimited on joins (matrix). Fixes #1201 (#1206)
|
||||
|
||||
This release couldn't exist without the following contributors:
|
||||
@42wim, @BenWiederhake, @Dellle, @gary-kim
|
||||
|
||||
# v1.18.0
|
||||
|
||||
## New features
|
||||
|
||||
- nctalk: new protocol added. Add Nextcloud Talk support #1167
|
||||
- general: Add an option to log into a file rather than stdout (#1168)
|
||||
- api: Add websocket to API (#970)
|
||||
|
||||
## Enhancements
|
||||
|
||||
- telegram: Fix MarkdownV2 support in Telegram (#1169)
|
||||
- whatsapp: Reload user information when a new contact is detected (whatsapp) (#1160)
|
||||
- api: Add sane RemoteNickFormat default for API (#1157)
|
||||
- irc: Skip gIRC built-in rate limiting (irc) (#1164)
|
||||
- irc: Only colour IRC nicks if there is one. (#1161)
|
||||
- docker: Combine runs to one layer (#1151)
|
||||
|
||||
## Bugfix
|
||||
|
||||
- general: Update dependencies for 1.18.0 release (#1175)
|
||||
|
||||
Discord users are encouraged to upgrade, this release works with the move to the discord.com domain.
|
||||
|
||||
This release couldn't exist without the following contributors:
|
||||
@42wim, @jlu5, @qaisjp, @TheHolyRoger, @SuperSandro2000, @gary-kim, @z3bra, @greenx, @haykam821, @nathanaelhoun
|
||||
|
||||
# v1.17.5
|
||||
|
||||
## Enhancements
|
||||
|
||||
- irc: Add StripMarkdown option (irc). (#1145)
|
||||
- general: Increase debug logging with function,file and linenumber (#1147)
|
||||
- general: Update Dockerfile so inotify works (#1148)
|
||||
- matrix: Add an option to disable sending HTML to matrix. Fixes #1022 (#1135)
|
||||
- xmpp: Implement xep-0245 (xmpp). Closes #1137 (#1144)
|
||||
|
||||
## Bugfix
|
||||
|
||||
- discord: Fix #1120: replaceAction "_" crash (discord) (#1121)
|
||||
- discord: Fix #1049: missing space before embeds (discord) (#1124)
|
||||
- discord: Fix webhook EventUserAction messages being skipped (discord) (#1133)
|
||||
- matrix: Avoid creating invalid url when the user doesn't have an avatar (matrix) (#1130)
|
||||
- msteams: Ignore non-user messages (msteams). Fixes #1141 (#1149)
|
||||
- slack: Do not use webhooks when token is configured (slack) (fixes #1123) (#1134)
|
||||
- telegram: Fix forward from hidden users (telegram). Closes #1131 (#1143)
|
||||
- xmpp: Prevent re-requesting avatar data (xmpp) (#1117)
|
||||
|
||||
This release couldn't exist without the following contributors:
|
||||
@qaisjp, @xnaas, @42wim, @Polynomdivision, @tfve
|
||||
|
||||
# v1.17.4
|
||||
|
||||
## Bugfix
|
||||
|
||||
- general: Lowercase account names. Fixes #1108 (#1110)
|
||||
- msteams: Remove panics and retry polling on failure (msteams). Fixes #1104 (#1105
|
||||
- whatsapp: Update Rhymen/go-whatsapp. Fixes #1107 (#1109) (make whatsapp working again)
|
||||
- discord: Add an ID cache (discord). Fixes #1106 (#1111) (fix delete/edits with webhooks)
|
||||
|
||||
# v1.17.3
|
||||
|
||||
## Enhancements
|
||||
|
||||
- xmpp: Implement User Avatar spoofing of XMPP users #1090
|
||||
- rocketchat: Relay Joins/Topic changes in RocketChat bridge (#1085)
|
||||
- irc: Add JoinDelay option (irc). Fixes #1084 (#1098)
|
||||
- slack: Clip too long messages on 3000 length (slack). Fixes #1081 (#1102)
|
||||
|
||||
## Bugfix
|
||||
|
||||
- general: Fix the behavior of ShowTopicChange and SyncTopic (#1086)
|
||||
- slack: Prevent image/message looping (slack). Fixes #1088 (#1096)
|
||||
- whatsapp: Ignore non-critical errors (whatsapp). Fixes #1094 (#1100)
|
||||
- irc: Add extra space before colon in attachments (irc). Fixes #1089 (#1101)
|
||||
|
||||
This release couldn't exist without the following contributors:
|
||||
@42wim, @ldruschk, @qaisjp, @Polynomdivision
|
||||
|
||||
# v1.17.2
|
||||
|
||||
## Enhancements
|
||||
|
||||
- slack: Update vendor slack-go/slack (#1068)
|
||||
- general: Update vendor d5/tengo (#1066)
|
||||
- general: Clarify terminology used in mapping group chat IDs to channels in config (#1079)
|
||||
|
||||
## Bugfix
|
||||
|
||||
- whatsapp: Update Rhymen/go-whatsapp vendor and whatsapp version (#1078). Fixes Media upload #1074
|
||||
- whatsapp: Reset start timestamp on reconnect (whatsapp). Fixes #1059 (#1064)
|
||||
|
||||
This release couldn't exist without the following contributors:
|
||||
@42wim, @jheiselman
|
||||
|
||||
# v1.17.1
|
||||
|
||||
## Enhancements
|
||||
|
||||
- docker: Remove build dependencies from final image (multistage build) #1057
|
||||
|
||||
## Bugfix
|
||||
|
||||
- general: Don't transmit typing events from ourselves #1056
|
||||
- general: Add support for build tags #1054
|
||||
- discord: Strip extra info from emotes (discord) #1052
|
||||
- msteams: fix macos build: Update vendor yaegashi/msgraph.go to v0.1.2 #1036
|
||||
- whatsapp: Update client version whatsapp. Fixes #1061 #1062
|
||||
|
||||
This release couldn't exist without the following contributors:
|
||||
@awigen, @qaisjp, @42wim
|
||||
|
||||
# v1.17.0
|
||||
|
||||
## New features
|
||||
|
||||
- msteams: new protocol added. Add initial Microsoft Teams support #967
|
||||
See https://github.com/42wim/matterbridge/wiki/MS-Teams-setup for a complete walkthrough
|
||||
- discord: Add ability to procure avatars from the destination bridge #1000
|
||||
- matrix: Add support for avatars from matrix. #1007
|
||||
- general: support JSON and YAML config formats #1045
|
||||
|
||||
## Enhancements
|
||||
|
||||
- discord: Check only bridged channels for PermManageWebhooks #1001
|
||||
- irc: Be less lossy when throttling IRC messages #1004
|
||||
- keybase: updated library #1002, #1019
|
||||
- matrix: Rebase gomatrix vendor with upstream #1006
|
||||
- slack: Use upstream slack-go/slack again #1018
|
||||
- slack: Ignore ConnectingEvent #1041
|
||||
- slack: use blocks not attachments #1048
|
||||
- sshchat: Update vendor shazow/ssh-chat #1029
|
||||
- telegram: added markdownv2 mode for telegram #1037
|
||||
- whatsapp: Implement basic reconnect (whatsapp). Fixes #987 #1003
|
||||
|
||||
## Bugfix
|
||||
|
||||
- discord: Fix webhook permission checks sometimes failing #1043
|
||||
- discord: Fix #1027: warning when handling inbound webhooks #1044
|
||||
- discord: Fix duplicate separator on empty description/url (discord) #1035
|
||||
- matrix: Fix issue with underscores in links #999
|
||||
- slack: Fix #1039: messages sent to Slack being synced back #1046
|
||||
- telegram: Make avatars download work with mediaserverdownload (telegram). Fixes #920
|
||||
|
||||
This release couldn't exist without the following contributors:
|
||||
@qaisjp, @jakubgs, @burner1024, @notpushkin, @MartijnBraam, @42wim
|
||||
|
||||
# v1.16.5
|
||||
|
||||
- Fix version bump
|
||||
|
||||
# v1.16.4
|
||||
|
||||
## New features
|
||||
|
||||
- whatsapp: Add support for WhatsApp media (jpeg/png/gif) bridging (#974)
|
||||
- telegram: Add QuoteLengthLimit option (telegram) fixes #963 (#985)
|
||||
- telegram: Add DisableWebPagePreview option (telegram). Closes #980 (#994)
|
||||
|
||||
## Enhancements
|
||||
|
||||
- general: update dependencies
|
||||
- tengo: update to tengo v2
|
||||
- general: Add Docker Compose configuration (#990)
|
||||
|
||||
## Bugfix
|
||||
|
||||
- general: Fail with message instead of panic. #988 (#991)
|
||||
- telegram: Add extra mimetypes to docker image. Fixes #969
|
||||
- discord: Fix channel ID problem with multiple gateways (discord). Fixes #953 (#977)
|
||||
- discord: Show file comment in webhook if normal message is empty (discord). Fixes #962 (#995)
|
||||
- matrix: Fix parsing issues - Disable smartypants in markdown parser. Fixes #989, #983 (#993)
|
||||
- sshchat: Fix duplicated messages (sshchat). Fixes #950 (#996)
|
||||
|
||||
This release couldn't exist without the following contributors:
|
||||
@jwflory, @42wim, @pbek, @Humorhenker, @c0ncord2, @glazzara
|
||||
|
||||
# v1.16.3
|
||||
|
||||
## Bugfix
|
||||
|
||||
- slack: Fix issues with ratelimiting #959
|
||||
- mattermost: Fix bug when using webhookURL and login/token together #960
|
||||
|
||||
# v1.16.2
|
||||
|
||||
## New features
|
||||
|
||||
- keybase: Add support for receiving attachments (keybase) (#923)
|
||||
|
||||
## Enhancements
|
||||
|
||||
- general: Switch to new emoji library kyokomi/emoji (#948)
|
||||
- general: Update markdown parsing library to github.com/gomarkdown/markdown (#944)
|
||||
- ssh-chat: Update shazow/ssh-chat dependency (#947)
|
||||
|
||||
## Bugfix
|
||||
|
||||
- slack: Fix issues with the slack block kit API #937 (#943).
|
||||
|
||||
This release couldn't exist without the following contributors:
|
||||
@42wim, @bmpickford, @goncalor
|
||||
|
||||
# v1.16.1
|
||||
|
||||
## New features
|
||||
|
||||
* rocketchat: add token support #892
|
||||
* matrix: Add support for uploading application/x and audio/x (matrix). #929
|
||||
|
||||
## Enhancements
|
||||
|
||||
* general: Do configuration validation on start-up. Fixes #888
|
||||
* general: updated vendored libraries (discord/whatsapp) #932
|
||||
* discord: user typing messages #914
|
||||
* slack: Convert slack bold/strike to correct markdown (slack). Fixes #918
|
||||
|
||||
## Bugfix
|
||||
|
||||
* discord: fix Failed to fetch information for members message. #894
|
||||
* discord: remove obsolete file upload links (discord). #931
|
||||
* slack: suppress unhandled HelloEvent message #913
|
||||
* mattermost: Fix panic on WebhookURL only setting (mattermost). #917
|
||||
* matrix: fix corrupted links between slack and matrix #924
|
||||
|
||||
This release couldn't exist without the following contributors:
|
||||
@qaisjp, @hramrach, @42wim
|
||||
|
||||
# v1.16.0
|
||||
|
||||
## New features
|
||||
|
||||
* keybase: new protocol added. Add initial Keybase Chat support #877 Thanks to @hyperobject
|
||||
* discord: Support webhook files in discord #872
|
||||
|
||||
## Enhancements
|
||||
|
||||
* general: update dependencies
|
||||
|
||||
## Bugfix
|
||||
|
||||
* discord: Underscores from Discord don't arrive correctly #864
|
||||
* xmpp: Fix possible panic at startup of the XMPP bridge #869
|
||||
* mattermost: Make getChannelIdTeam behave like GetChannelId for groups (mattermost) #873
|
||||
|
||||
This release couldn't exist without the following contributors:
|
||||
@hyperobject, @42wim, @bucko909, @MOZGIII
|
||||
|
||||
# v1.15.1
|
||||
|
||||
## New features
|
||||
* discord: Support webhook message deletions (discord) (#853)
|
||||
|
||||
## Enhancements
|
||||
|
||||
* discord: Support bulk deletions #851
|
||||
* discord: Support channels in categories #863 (use category/channel. See matterbridge.toml.sample for more info)
|
||||
* mattermost: Add an option to skip the Mattermost server version check #849
|
||||
|
||||
## Bugfix
|
||||
|
||||
* xmpp: fix segfault when disconnected/reconnected #856
|
||||
* telegram: fix panic in handleEntities #858
|
||||
|
||||
This release couldn't exist without the following contributors:
|
||||
@42wim, @qaisjp, @joohoi
|
||||
|
||||
# v1.15.0
|
||||
## New features
|
||||
* Add scripting (tengo) support for every outgoing message (#806)
|
||||
See https://github.com/42wim/matterbridge/wiki/Settings#tengo and
|
||||
https://github.com/42wim/matterbridge/wiki/Settings#outmessage for more information
|
||||
* Add tengo support to RemoteNickFormat (#793)
|
||||
See https://github.com/42wim/matterbridge/wiki/Settings#remotenickformat-2
|
||||
* Deprecated `Message` under `[tengo]` to `InMessage`
|
||||
|
||||
## Enhancements
|
||||
* general: Forward only user-typing messages if supported by protocol (#832)
|
||||
* general: updated wiki with all possible settings: https://github.com/42wim/matterbridge/wiki/Settings
|
||||
* tengo: Add msg event to tengo
|
||||
* xmpp: Verify TLS against JID domain, not the host. (xmpp) (#834)
|
||||
* xmpp: Allow messages with timestamp (xmpp). Fixes #835 (#847)
|
||||
* irc: Add verbose IRC joins/parts (ident@host) (#805)
|
||||
See https://github.com/42wim/matterbridge/wiki/Settings#verbosejoinpart
|
||||
* rocketchat: Add useraction support (rocketchat). Closes #772 (#794)
|
||||
|
||||
## Bugfix
|
||||
* slack: Fix regression in autojoining with legacy tokens (slack). Fixes #651 (#848)
|
||||
* xmpp: Revert xmpp to orig behaviour. Closes #844
|
||||
* whatsapp: Update github.com/Rhymen/go-whatsapp vendor. Fixes #843
|
||||
* mattermost: Update channels of all teams (mattermost)
|
||||
|
||||
This release couldn't exist without the following contributors:
|
||||
@42wim, @Helcaraxan, @chotaire, @qaisjp, @dajohi, @kousu
|
||||
|
||||
# v1.14.4
|
||||
|
||||
## Bugfix
|
||||
* mattermost: Add Id to EditMessage (mattermost). Fixes #802
|
||||
* mattermost: Fix panic on nil message.Post (mattermost). Fixes #804
|
||||
* mattermost: Handle unthreaded messages (mattermost). Fixes #803
|
||||
* mattermost: Use paging in initUser and UpdateUsers (mattermost)
|
||||
* slack: Add lacking clean-up in Slack synchronisation (#811)
|
||||
* slack: Disable user lookups on delete messages (slack) (#812)
|
||||
|
||||
# v1.14.3
|
||||
|
||||
## Bugfix
|
||||
* irc: Fix deadlock on reconnect (irc). Closes #757
|
||||
|
||||
# v1.14.2
|
||||
|
||||
## Bugfix
|
||||
* general: Update tengo vendor and load the stdlib. Fixes #789 (#792)
|
||||
* rocketchat: Look up #channel too (rocketchat). Fix #773 (#775)
|
||||
* slack: Ignore messagereplied and hidden messages (slack). Fixes #709 (#779)
|
||||
* telegram: Handle nil message (telegram). Fixes #777
|
||||
* irc: Use default nick if none specified (irc). Fixes #785
|
||||
* irc: Return when not connected and drop a message (irc). Fixes #786
|
||||
* irc: Revert fix for #722 (Support quits from irc correctly). Closes #781
|
||||
|
||||
## Contributors
|
||||
This release couldn't exist without the following contributors:
|
||||
@42wim, @Helcaraxan, @dajohi
|
||||
|
||||
# v1.14.1
|
||||
## Bugfix
|
||||
* slack: Fix crash double unlock (slack) (#771)
|
||||
|
||||
# v1.14.0
|
||||
|
||||
## Breaking
|
||||
* zulip: Need to specify /topic:mytopic for channel configuration (zulip). (#751)
|
||||
|
||||
## New features
|
||||
* whatsapp: new protocol added. Add initial WhatsApp support (#711) Thanks to @KrzysztofMadejski
|
||||
* facebook messenger: new protocol via matterbridge api. See https://github.com/VictorNine/fbridge/ for more information.
|
||||
* general: Add scripting (tengo) support for every incoming message (#731). See `TengoModifyMessage`
|
||||
* general: Allow regexs in ignoreNicks. Closes #690 (#720)
|
||||
* general: Support rewriting messages from relaybots using ExtractNicks. Fixes #466 (#730). See `ExtractNicks` in matterbridge.toml.sample
|
||||
* general: refactor Make all loggers derive from non-default instance (#728). Thanks to @Helcaraxan
|
||||
* rocketchat: add support for the rocketchat API. Sending to rocketchat now supports uploading of files, editing and deleting of messages.
|
||||
* discord: Support join/leaves from discord. Closes #654 (#721)
|
||||
* discord: Allow sending discriminator with Discord username (#726). See `UseDiscriminator` in matterbridge.toml.sample
|
||||
* slack: Add extra debug option (slack). See `Debug` in the slack section in matterbridge.toml.sample
|
||||
* telegram: Add support for URL in messageEntities (telegram). Fixes #735 (#736)
|
||||
* telegram: Add MediaConvertWebPToPNG option (telegram). (#741). See `MediaConvertWebPToPNG` in matterbridge.toml.sample
|
||||
|
||||
## Enhancements
|
||||
* general: Fail gracefully on incorrect human input. Fixes #739 (#740)
|
||||
* matrix: Detect html nicks in RemoteNickFormat (matrix). Fixes #696 (#719)
|
||||
* matrix: Send notices on join/parts (matrix). Fixes #712 (#716)
|
||||
|
||||
## Bugfix
|
||||
* general: Handle file upload/download only once for each message (#742)
|
||||
* zulip: Fix error handling on bad event queue id (zulip). Closes #694
|
||||
* zulip: Keep reconnecting until succeed (zulip) (#737)
|
||||
* irc: add support for (older) unrealircd versions. #708
|
||||
* irc: Support quits from irc correctly. Fixes #722 (#724)
|
||||
* matrix: Send username when uploading video/images (matrix). Fixes #715 (#717)
|
||||
* matrix: Trim <p> and </p> tags (matrix). Closes #686 (#753)
|
||||
* slack: Hint at thread replies when messages are unthreaded (slack) (#684)
|
||||
* slack: Fix race-condition in populateUser() (#767)
|
||||
* xmpp: Do not send topic changes on connect (xmpp). Fixes #732 (#733)
|
||||
* telegram: Fix regression in HTML handling (telegram). Closes #734
|
||||
* discord: Do not relay any bot messages (discord) (#743)
|
||||
* rocketchat: Do not send duplicate messages (rocketchat). Fixes #745 (#752)
|
||||
|
||||
## Contributors
|
||||
This release couldn't exist without the following contributors:
|
||||
@Helcaraxan, @KrzysztofMadejski, @AJolly, @DeclanHoare
|
||||
|
||||
# v1.13.1
|
||||
|
||||
This release fixes go modules issues because of https://github.com/labstack/echo/issues/1272
|
||||
|
||||
## Bugfix
|
||||
* general: fixes Unable to build 1.13.0 #698
|
||||
* api: move to labstack/echo/v4 fixes #698
|
||||
|
||||
# v1.13.0
|
||||
|
||||
## New features
|
||||
* general: refactors of telegram, irc, mattermost, matrix, discord, sshchat bridges and the gateway.
|
||||
* irc: Add option to send RAW commands after connection (irc) #490. See `RunCommands` in matterbridge.toml.sample
|
||||
* mattermost: 3.x support dropped
|
||||
* mattermost: Add support for mattermost threading (#627)
|
||||
* slack: Sync channel topics between Slack bridges #585. See `SyncTopic` in matterbridge.toml.sample
|
||||
* matrix: Add support for markdown to HTML conversion (matrix). Closes #663 (#670)
|
||||
* discord: Improve error reporting on failure to join Discord. Fixes #672 (#680)
|
||||
* discord: Use only one webhook if possible (discord) (#681)
|
||||
* discord: Allow to bridge non-bot Discord users (discord) (#689) If you prefix a token with `User ` it'll treat is as a user token.
|
||||
|
||||
## Bugfix
|
||||
* slack: Try downloading files again if slack is too slow (slack). Closes #655 (#656)
|
||||
* slack: Ignore LatencyReport event (slack)
|
||||
* slack: Fix #668 strip lang in code fences sent to Slack (#673)
|
||||
* sshchat: Fix sshchat connection logic (#661)
|
||||
* sshchat: set quiet mode to filter joins/quits
|
||||
* sshchat: Trim newlines in the end of relayed messages
|
||||
* sshchat: fix media links
|
||||
* sshchat: do not relay "Rate limiting is in effect" message
|
||||
* mattermost: Fail if channel starts with hashtag (mattermost). Closes #625
|
||||
* discord: Add file comment to webhook messages (discord). Fixes #358
|
||||
* matrix: Fix displaying usernames for plain text clients. (matrix) (#685)
|
||||
* irc: Fix possible data race (irc). Closes #693
|
||||
* irc: Handle servers without MOTD (irc). Closes #692
|
||||
|
||||
# v1.12.3
|
||||
## Bugfix
|
||||
* slack: Fix bot (legacy token) messages not being send. Closes #571
|
||||
* slack: Populate user on channel join (slack) (#644)
|
||||
* slack: Add wait option for populateUsers/Channels (slack) Fixes #579 (#653)
|
||||
|
||||
# v1.12.2
|
||||
|
||||
## Bugfix
|
||||
* irc: Fix multiple channel join regression. Closes #639
|
||||
* slack: Make slack-legacy change less restrictive (#626)
|
||||
|
||||
# v1.12.1
|
||||
|
||||
## Bugfix
|
||||
* discord: fix regression on server ID connection #619 #617
|
||||
* discord: Limit discord username via webhook to 32 chars
|
||||
* slack: Make sure threaded files stay in thread (slack). Fixes #590
|
||||
* slack: Do not post empty messages (slack). Fixes #574
|
||||
* slack: Handle deleted/edited thread starting messages (slack). Fixes #600 (#605)
|
||||
* irc: Rework connection logic (irc)
|
||||
* irc: Fix Nickserv logic (irc) #602
|
||||
|
||||
# v1.12.0
|
||||
|
||||
## Breaking changes
|
||||
The slack bridge has been split in a `slack-legacy` and `slack` bridge.
|
||||
If you're still using `legacy tokens` and want to keep using them you'll have to rename `slack` to `slack-legacy` in your configuration. See [wiki](https://github.com/42wim/matterbridge/wiki/Section-Slack-(basic)#legacy-configuration) for more information.
|
||||
|
||||
To migrate to the new bot-token based setup you can follow the instructions [here](https://github.com/42wim/matterbridge/wiki/Slack-bot-setup).
|
||||
|
||||
Slack legacy tokens may be deprecated by Slack at short notice, so it is STRONGLY recommended to use a proper bot-token instead.
|
||||
|
||||
## New features
|
||||
* general: New {GATEWAY} variable for `RemoteNickFormat` #501. See `RemoteNickFormat` in matterbridge.toml.sample.
|
||||
* general: New {CHANNEL} variable for `RemoteNickFormat` #515. See `RemoteNickFormat` in matterbridge.toml.sample.
|
||||
* general: Remove hyphens when auto-loading envvars from viper config #545
|
||||
* discord: You can mention discord-users from other bridges.
|
||||
* slack: Preserve threading between Slack instances #529. See `PreserveThreading` in matterbridge.toml.sample.
|
||||
* slack: Add ability to show when user is typing across Slack bridges #559
|
||||
* slack: Add rate-limiting
|
||||
* mattermost: Add support for mattermost [matterbridge plugin](https://github.com/matterbridge/mattermost-plugin)
|
||||
* api: Respond with message on connect. #550
|
||||
* api: Add a health endpoint to API #554
|
||||
|
||||
## Bugfix
|
||||
* slack: Refactoring and making it better.
|
||||
* slack: Restore file comments coming from Slack. #583
|
||||
* irc: Fix IRC line splitting. #587
|
||||
* mattermost: Fix cookie and personal token behaviour. #530
|
||||
* mattermost: Check for expiring sessions and reconnect.
|
||||
|
||||
|
||||
## Contributors
|
||||
This release couldn't exist without the following contributors:
|
||||
@jheiselman, @NikkyAI, @dajohi, @NetwideRogue, @patcon and @Helcaraxan
|
||||
|
||||
Special thanks to @Helcaraxan and @patcon for their work on improving/refactoring slack.
|
||||
|
||||
# v1.11.3
|
||||
|
||||
## Bugfix
|
||||
* mattermost: fix panic when using webhooks #491
|
||||
* slack: fix issues regarding API changes and lots of channels #489
|
||||
* irc: fix rejoin on kick problem #488
|
||||
|
||||
# v1.11.2
|
||||
|
||||
## Bugfix
|
||||
* slack: fix slack API changes regarding to files/images
|
||||
|
||||
# v1.11.1
|
||||
|
||||
## New features
|
||||
* slack: Add support for slack channels by ID. Closes #436
|
||||
* discord: Clip too long messages sent to discord (discord). Closes #440
|
||||
|
||||
## Bugfix
|
||||
* general: fix possible panic on downloads that are too big #448
|
||||
* general: Fix avatar uploads to work with MediaDownloadPath. Closes #454
|
||||
* discord: allow receiving of topic changes/channel leave/joins from other bridges through the webhook
|
||||
* discord: Add a space before url in file uploads (discord). Closes #461
|
||||
* discord: Skip empty messages being sent with the webhook (discord). #469
|
||||
* mattermost: Use nickname instead of username if defined (mattermost). Closes #452
|
||||
* irc: Stop numbers being stripped after non-color control codes (irc) (#465)
|
||||
* slack: Use UserID to look for avatar instead of username (slack). Closes #472
|
||||
|
||||
# v1.11.0
|
||||
|
||||
## New features
|
||||
* general: Add config option MediaDownloadPath (#443). See `MediaDownloadPath` in matterbridge.toml.sample
|
||||
* general: Add MediaDownloadBlacklist option. Closes #442. See `MediaDownloadBlacklist` in matterbridge.toml.sample
|
||||
* xmpp: Add channel password support for XMPP (#451)
|
||||
* xmpp: Add message correction support for XMPP (#437)
|
||||
* telegram: Add support for MessageFormat=htmlnick (telegram). #444
|
||||
* mattermost: Add support for mattermost 5.x
|
||||
|
||||
## Enhancements
|
||||
* slack: Add Title from attachment slack message (#446)
|
||||
* irc: Prevent white or black color codes (irc) (#434)
|
||||
|
||||
## Bugfix
|
||||
* slack: Fix regexp in replaceMention (slack). (#435)
|
||||
* irc: Reconnect on quit. (irc) See #431 (#445)
|
||||
* sshchat: Ignore messages from ourself. (sshchat) Closes #439
|
||||
|
||||
# v1.10.1
|
||||
## New features
|
||||
* irc: Colorize username sent to IRC using its crc32 IEEE checksum (#423). See `ColorNicks` in matterbridge.toml.sample
|
||||
* irc: Add support for CJK to/from utf-8 (irc). #400
|
||||
* telegram: Add QuoteFormat option (telegram). Closes #413. See `QuoteFormat` in matterbridge.toml.sample
|
||||
* xmpp: Send attached files to XMPP in different message with OOB data and without body (#421)
|
||||
|
||||
## Bugfix
|
||||
* general: updated irc/xmpp/telegram libraries
|
||||
* mattermost/slack/rocketchat: Fix iconurl regression. Closes #430
|
||||
* mattermost/slack: Use uuid instead of userid. Fixes #429
|
||||
* slack: Avatar spoofing from Slack to Discord with uppercase in nick doesn't work (#433)
|
||||
* irc: Fix format string bug (irc) (#428)
|
||||
|
||||
# v1.10.0
|
||||
## New features
|
||||
* general: Add support for reloading all settings automatically after changing config except connection and gateway configuration. Closes #373
|
||||
* zulip: New protocol support added (https://zulipchat.com)
|
||||
|
||||
## Enhancements
|
||||
* general: Handle file comment better
|
||||
* steam: Handle file uploads to mediaserver (steam)
|
||||
* slack: Properly set Slack user who initiated slash command (#394)
|
||||
|
||||
## Bugfix
|
||||
* general: Use only alphanumeric for file uploads to mediaserver. Closes #416
|
||||
* general: Fix crash on invalid filenames
|
||||
* general: Fix regression in ReplaceMessages and ReplaceNicks. Closes #407
|
||||
* telegram: Fix possible nil when using channels (telegram). #410
|
||||
* telegram: Fix panic (telegram). Closes #410
|
||||
* telegram: Handle channel posts correctly
|
||||
* mattermost: Update GetFileLinks to API_V4
|
||||
|
||||
# v1.9.1
|
||||
## New features
|
||||
* telegram: Add QuoteDisable option (telegram). Closes #399. See QuoteDisable in matterbridge.toml.sample
|
||||
## Enhancements
|
||||
* discord: Send mediaserver link to Discord in Webhook mode (discord) (#405)
|
||||
* mattermost: Print list of valid team names when team not found (#390)
|
||||
* slack: Strip markdown URLs with blank text (slack) (#392)
|
||||
## Bugfix
|
||||
* slack/mattermost: Make our callbackid more unique. Fixes issue with running multiple matterbridge on the same channel (slack,mattermost)
|
||||
* telegram: fix newlines in multiline messages #399
|
||||
* telegram: Revert #378
|
||||
|
||||
# v1.9.0 (the refactor release)
|
||||
## New features
|
||||
* general: better debug messages
|
||||
* general: better support for environment variables override
|
||||
* general: Ability to disable sending join/leave messages to other gateways. #382
|
||||
* slack: Allow Slack @usergroups to be parsed as human-friendly names #379
|
||||
* slack: Provide better context for shared posts from Slack<=>Slack enhancement #369
|
||||
* telegram: Convert nicks automatically into HTML when MessageFormat is set to HTML #378
|
||||
* irc: Add DebugLevel option
|
||||
|
||||
## Bugfix
|
||||
* slack: Ignore restricted_action on channel join (slack). Closes #387
|
||||
* slack: Add slack attachment support to matterhook
|
||||
* slack: Update userlist on join (slack). Closes #372
|
||||
|
||||
# v1.8.0
|
||||
## New features
|
||||
* general: Send chat notification if media is too big to be re-uploaded to MediaServer. See #359
|
||||
* general: Download (and upload) avatar images from mattermost and telegram when mediaserver is configured. Closes #362
|
||||
* general: Add label support in RemoteNickFormat
|
||||
* general: Prettier info/debug log output
|
||||
* mattermost: Download files and reupload to supported bridges (mattermost). Closes #357
|
||||
* slack: Add ShowTopicChange option. Allow/disable topic change messages (currently only from slack). Closes #353
|
||||
* slack: Add support for file comments (slack). Closes #346
|
||||
* telegram: Add comment to file upload from telegram. Show comments on all bridges. Closes #358
|
||||
* telegram: Add markdown support (telegram). #355
|
||||
* api: Give api access to whole config.Message (and events). Closes #374
|
||||
|
||||
## Bugfix
|
||||
* discord: Check for a valid WebhookURL (discord). Closes #367
|
||||
* discord: Fix role mention replace issues
|
||||
* irc: Truncate messages sent to IRC based on byte count (#368)
|
||||
* mattermost: Add file download urls also to mattermost webhooks #356
|
||||
* telegram: Fix panic on nil messages (telegram). Closes #366
|
||||
* telegram: Fix the UseInsecureURL text (telegram). Closes #184
|
||||
|
||||
# v1.7.1
|
||||
## Bugfix
|
||||
* telegram: Enable Long Polling for Telegram. Reduces bandwidth consumption. (#350)
|
||||
|
||||
# v1.7.0
|
||||
## New features
|
||||
* matrix: Add support for deleting messages from/to matrix (matrix). Closes #320
|
||||
* xmpp: Ignore <subject> messages (xmpp). #272
|
||||
* irc: Add twitch support (irc) to README / wiki
|
||||
|
||||
## Bugfix
|
||||
* general: Change RemoteNickFormat replacement order. Closes #336
|
||||
* general: Make edits/delete work for bridges that gets reused. Closes #342
|
||||
* general: Lowercase irc channels in config. Closes #348
|
||||
* matrix: Fix possible panics (matrix). Closes #333
|
||||
* matrix: Add an extension to images without one (matrix). #331
|
||||
* api: Obey the Gateway value from the json (api). Closes #344
|
||||
* xmpp: Print only debug messages when specified (xmpp). Closes #345
|
||||
* xmpp: Allow xmpp to receive the extra messages (file uploads) when text is empty. #295
|
||||
|
||||
# v1.6.3
|
||||
## Bugfix
|
||||
* slack: Fix connection issues
|
||||
* slack: Add more debug messages
|
||||
* irc: Convert received IRC channel names to lowercase. Fixes #329 (#330)
|
||||
|
||||
# v1.6.2
|
||||
## Bugfix
|
||||
* mattermost: Crashes while connecting to Mattermost (regression). Closes #327
|
||||
|
||||
# v1.6.1
|
||||
## Bugfix
|
||||
* general: Display of nicks not longer working (regression). Closes #323
|
||||
|
||||
# v1.6.0
|
||||
## New features
|
||||
* sshchat: New protocol support added (https://github.com/shazow/ssh-chat)
|
||||
* general: Allow specifying maximum download size of media using MediaDownloadSize (slack,telegram,matrix)
|
||||
* api: Add (simple, one listener) long-polling support (api). Closes #307
|
||||
* telegram: Add support for forwarded messages. Closes #313
|
||||
* telegram: Add support for Audio/Voice files (telegram). Closes #314
|
||||
* irc: Add RejoinDelay option. Delay to rejoin after channel kick (irc). Closes #322
|
||||
|
||||
## Bugfix
|
||||
* telegram: Also use HTML in edited messages (telegram). Closes #315
|
||||
* matrix: Fix panic (matrix). Closes #316
|
||||
|
||||
# v1.5.1
|
||||
|
||||
## Bugfix
|
||||
* irc: Fix irc ACTION regression (irc). Closes #306
|
||||
* irc: Split on UTF-8 for MessageSplit (irc). Closes #308
|
||||
|
||||
# v1.5.0
|
||||
## New features
|
||||
* general: remote mediaserver support. See MediaServerDownload and MediaServerUpload in matterbridge.toml.sample
|
||||
more information on https://github.com/42wim/matterbridge/wiki/Mediaserver-setup-%5Badvanced%5D
|
||||
* general: Add support for ReplaceNicks using regexp to replace nicks. Closes #269 (see matterbridge.toml.sample)
|
||||
* general: Add support for ReplaceMessages using regexp to replace messages. #269 (see matterbridge.toml.sample)
|
||||
* irc: Add MessageSplit option to split messages on MessageLength (irc). Closes #281
|
||||
* matrix: Add support for uploading images/video (matrix). Closes #302
|
||||
* matrix: Add support for uploaded images/video (matrix)
|
||||
|
||||
## Bugfix
|
||||
* telegram: Add webp extension to stickers if necessary (telegram)
|
||||
* mattermost: Break when re-login fails (mattermost)
|
||||
|
||||
# v1.4.1
|
||||
## Bugfix
|
||||
* telegram: fix issue with uploading for images/documents/stickers
|
||||
* slack: remove double messages sent to other bridges when uploading files
|
||||
* irc: Fix strict user handling of girc (irc). Closes #298
|
||||
|
||||
# v1.4.0
|
||||
## Breaking changes
|
||||
* general: `[general]` settings don't override the specific bridge settings
|
||||
|
||||
## New features
|
||||
* irc: Replace sorcix/irc and go-ircevent with girc, this should be give better reconnects
|
||||
* steam: Add support for bridging to individual steam chats. (steam) (#294)
|
||||
* telegram: Download files from telegram and reupload to supported bridges (telegram). #278
|
||||
* slack: Add support to upload files to slack, from bridges with private urls like slack/mattermost/telegram. (slack)
|
||||
* discord: Add support to upload files to discord, from bridges with private urls like slack/mattermost/telegram. (discord)
|
||||
* general: Add systemd service file (#291)
|
||||
* general: Add support for DEBUG=1 envvar to enable debug. Closes #283
|
||||
* general: Add StripNick option, only allow alphanumerical nicks. Closes #285
|
||||
|
||||
## Bugfix
|
||||
* gitter: Use room.URI instead of room.Name. (gitter) (#293)
|
||||
* slack: Allow slack messages with variables (eg. @here) to be formatted correctly. (slack) (#288)
|
||||
* slack: Resolve slack channel to human-readable name. (slack) (#282)
|
||||
* slack: Use DisplayName instead of deprecated username (slack). Closes #276
|
||||
* slack: Allowed Slack bridge to extract simpler link format. (#287)
|
||||
* irc: Strip irc colors correct, strip also ctrl chars (irc)
|
||||
|
||||
# v1.3.1
|
||||
## New features
|
||||
* Support mattermost 4.3.0 and every other 4.x as api4 should be stable (mattermost)
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
#!/bin/bash
|
||||
go version |grep go1.9 || exit
|
||||
VERSION=$(git describe --tags)
|
||||
mkdir ci/binaries
|
||||
GOOS=windows GOARCH=amd64 go build -ldflags "-s -w -X main.githash=$(git log --pretty=format:'%h' -n 1)" -o ci/binaries/matterbridge-$VERSION-windows-amd64.exe
|
||||
GOOS=linux GOARCH=amd64 go build -ldflags "-s -w -X main.githash=$(git log --pretty=format:'%h' -n 1)" -o ci/binaries/matterbridge-$VERSION-linux-amd64
|
||||
GOOS=linux GOARCH=arm go build -ldflags "-s -w -X main.githash=$(git log --pretty=format:'%h' -n 1)" -o ci/binaries/matterbridge-$VERSION-linux-arm
|
||||
GOOS=darwin GOARCH=amd64 go build -ldflags "-s -w -X main.githash=$(git log --pretty=format:'%h' -n 1)" -o ci/binaries/matterbridge-$VERSION-darwin-amd64
|
||||
cd ci
|
||||
cat > deploy.json <<EOF
|
||||
{
|
||||
"package": {
|
||||
"name": "Matterbridge",
|
||||
"repo": "nightly",
|
||||
"subject": "42wim"
|
||||
},
|
||||
"version": {
|
||||
"name": "$VERSION"
|
||||
},
|
||||
"files":
|
||||
[
|
||||
{"includePattern": "ci/binaries/(.*)", "uploadPattern":"\$1"}
|
||||
],
|
||||
"publish": true
|
||||
}
|
||||
EOF
|
||||
|
||||
210
contrib/api.yaml
Normal file
210
contrib/api.yaml
Normal file
@@ -0,0 +1,210 @@
|
||||
openapi: 3.0.0
|
||||
info:
|
||||
contact: {}
|
||||
description: A read/write API for the Matterbridge chat bridge.
|
||||
license:
|
||||
name: Apache 2.0
|
||||
url: 'https://github.com/42wim/matterbridge/blob/master/LICENSE'
|
||||
title: Matterbridge API
|
||||
version: "0.1.0-oas3"
|
||||
paths:
|
||||
/health:
|
||||
get:
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
'*/*':
|
||||
schema:
|
||||
type: string
|
||||
summary: Checks if the server is alive.
|
||||
/message:
|
||||
post:
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/config.OutgoingMessageResponse'
|
||||
summary: Create a message
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/config.OutgoingMessage'
|
||||
description: Message object to create
|
||||
required: true
|
||||
/messages:
|
||||
get:
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/components/schemas/config.IncomingMessage'
|
||||
type: array
|
||||
security:
|
||||
- ApiKeyAuth: []
|
||||
summary: List new messages
|
||||
/stream:
|
||||
get:
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/x-json-stream:
|
||||
schema:
|
||||
$ref: '#/components/schemas/config.IncomingMessage'
|
||||
summary: Stream realtime messages
|
||||
servers:
|
||||
- url: /api
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
schemas:
|
||||
config.IncomingMessage:
|
||||
properties:
|
||||
avatar:
|
||||
description: URL to an avatar image
|
||||
example: >-
|
||||
https://secure.gravatar.com/avatar/1234567890abcdef1234567890abcdef.jpg
|
||||
type: string
|
||||
event:
|
||||
description: >-
|
||||
A specific matterbridge event. (see
|
||||
https://github.com/42wim/matterbridge/blob/master/bridge/config/config.go#L16)
|
||||
type: string
|
||||
gateway:
|
||||
description: Name of the gateway as configured in matterbridge.toml
|
||||
example: mygateway
|
||||
type: string
|
||||
text:
|
||||
description: Content of the message
|
||||
example: 'Testing, testing, 1-2-3.'
|
||||
type: string
|
||||
username:
|
||||
description: Human-readable username
|
||||
example: alice
|
||||
type: string
|
||||
account:
|
||||
description: Unique account name of format "[protocol].[slug]" as defined in matterbridge.toml
|
||||
example: slack.myteam
|
||||
type: string
|
||||
channel:
|
||||
description: Human-readable channel name of sending bridge
|
||||
example: test-channel
|
||||
type: string
|
||||
id:
|
||||
description: Unique ID of message on the gateway
|
||||
example: slack 1541361213.030700
|
||||
type: string
|
||||
parent_id:
|
||||
description: Unique ID of a parent message, if threaded
|
||||
example: slack 1541361213.030700
|
||||
type: string
|
||||
protocol:
|
||||
description: Chat protocol of the sending bridge
|
||||
example: slack
|
||||
type: string
|
||||
timestamp:
|
||||
description: Timestamp of the message
|
||||
example: "1541361213.030700"
|
||||
type: string
|
||||
userid:
|
||||
description: Userid on the sending bridge
|
||||
example: U4MCXJKNC
|
||||
type: string
|
||||
extra:
|
||||
description: Extra data that doesn't fit in other fields (eg base64 encoded files)
|
||||
type: object
|
||||
config.OutgoingMessage:
|
||||
properties:
|
||||
avatar:
|
||||
description: URL to an avatar image
|
||||
example: >-
|
||||
https://secure.gravatar.com/avatar/1234567890abcdef1234567890abcdef.jpg
|
||||
type: string
|
||||
event:
|
||||
description: >-
|
||||
A specific matterbridge event. (see
|
||||
https://github.com/42wim/matterbridge/blob/master/bridge/config/config.go#L16)
|
||||
example: ""
|
||||
type: string
|
||||
gateway:
|
||||
description: Name of the gateway as configured in matterbridge.toml
|
||||
example: mygateway
|
||||
type: string
|
||||
text:
|
||||
description: Content of the message
|
||||
example: 'Testing, testing, 1-2-3.'
|
||||
type: string
|
||||
username:
|
||||
description: Human-readable username
|
||||
example: alice
|
||||
type: string
|
||||
type: object
|
||||
required:
|
||||
- gateway
|
||||
- text
|
||||
- username
|
||||
config.OutgoingMessageResponse:
|
||||
properties:
|
||||
avatar:
|
||||
description: URL to an avatar image
|
||||
example: >-
|
||||
https://secure.gravatar.com/avatar/1234567890abcdef1234567890abcdef.jpg
|
||||
type: string
|
||||
event:
|
||||
description: >-
|
||||
A specific matterbridge event. (see
|
||||
https://github.com/42wim/matterbridge/blob/master/bridge/config/config.go#L16)
|
||||
example: ""
|
||||
type: string
|
||||
gateway:
|
||||
description: Name of the gateway as configured in matterbridge.toml
|
||||
example: mygateway
|
||||
type: string
|
||||
text:
|
||||
description: Content of the message
|
||||
example: 'Testing, testing, 1-2-3.'
|
||||
type: string
|
||||
username:
|
||||
description: Human-readable username
|
||||
example: alice
|
||||
type: string
|
||||
account:
|
||||
description: fixed api account
|
||||
example: api.local
|
||||
type: string
|
||||
channel:
|
||||
description: fixed api channel
|
||||
example: api
|
||||
type: string
|
||||
id:
|
||||
example: ""
|
||||
type: string
|
||||
parent_id:
|
||||
example: ""
|
||||
type: string
|
||||
protocol:
|
||||
description: fixed api protocol
|
||||
example: api
|
||||
type: string
|
||||
timestamp:
|
||||
description: Timestamp of the message
|
||||
example: "1541361213.030700"
|
||||
type: string
|
||||
userid:
|
||||
example: ""
|
||||
type: string
|
||||
extra:
|
||||
example: null
|
||||
type: object
|
||||
type: object
|
||||
security:
|
||||
- bearerAuth: []
|
||||
2
contrib/example.tengo
Normal file
2
contrib/example.tengo
Normal file
@@ -0,0 +1,2 @@
|
||||
text := import("text")
|
||||
msgText=text.re_replace("matterbridge",msgText,"matterbridge (https://github.com/42wim/matterbridge)")
|
||||
11
contrib/matterbridge.service
Normal file
11
contrib/matterbridge.service
Normal file
@@ -0,0 +1,11 @@
|
||||
[Unit]
|
||||
Description=matterbridge
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/bin/matterbridge -conf /etc/matterbridge/bridge.toml
|
||||
User=matterbridge
|
||||
Group=matterbridge
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
10
contrib/outmessage-discordemoji.tengo
Normal file
10
contrib/outmessage-discordemoji.tengo
Normal file
@@ -0,0 +1,10 @@
|
||||
text := import("text")
|
||||
|
||||
// if we're not sending to a discord bridge,
|
||||
// then convert custom emoji tags into url's
|
||||
if (inProtocol == "discord" && outProtocol != "discord") {
|
||||
rePNG := text.re_compile(`<:.*?:([0-9]+)>`)
|
||||
msgText=rePNG.replace(msgText,"https://cdn.discordapp.com/emojis/$1.png")
|
||||
reGIF := text.re_compile(`<a:.*?:([0-9]+)>`)
|
||||
msgText=reGIF.replace(msgText,"https://cdn.discordapp.com/emojis/$1.gif")
|
||||
}
|
||||
14
contrib/outmessage-irccolornick.tengo
Normal file
14
contrib/outmessage-irccolornick.tengo
Normal file
@@ -0,0 +1,14 @@
|
||||
// See https://github.com/42wim/matterbridge/issues/881
|
||||
// Generates a colored nick for each msgUsername, with example to filter specific codes
|
||||
|
||||
text := import("text")
|
||||
fmt := import("fmt")
|
||||
if outProtocol == "irc" {
|
||||
// generate a color for a nick, make sure it isn't 0 or 15
|
||||
colorCode := len(msgUsername)+bytes(msgUsername)[0]%14 + 2
|
||||
// example if we want to use colorCode 3 when we have calculated colorcode 14
|
||||
if colorCode == 14 {
|
||||
colorCode = 3
|
||||
}
|
||||
msgUsername=fmt.sprintf("\x03%02d%s\x0F", colorCode, msgUsername)
|
||||
}
|
||||
7
contrib/outmessage-irccolors.tengo
Normal file
7
contrib/outmessage-irccolors.tengo
Normal file
@@ -0,0 +1,7 @@
|
||||
// See https://github.com/42wim/matterbridge/issues/798
|
||||
|
||||
// if we're not sending to an irc bridge we strip the IRC colors
|
||||
if outProtocol != "irc" {
|
||||
re := text.re_compile(`\x03(?:\d{1,2}(?:,\d{1,2})?)?|[[:cntrl:]]`)
|
||||
msgText=re.replace(msgText,"")
|
||||
}
|
||||
16
contrib/remotenickformat-zerowidth.tengo
Normal file
16
contrib/remotenickformat-zerowidth.tengo
Normal file
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
This script will return the nick except with multi-character usernames
|
||||
containing a zero-width space between the first and second character letter.
|
||||
|
||||
Single character usernames will be left untouched.
|
||||
|
||||
This is useful to prevent remote users from nickalerting
|
||||
IRC users of the same name when the remote user speaks.
|
||||
|
||||
This result can be used in {TENGO} in RemoteNickFormat.
|
||||
*/
|
||||
|
||||
result = nick
|
||||
if len(nick) > 1 {
|
||||
result = string(nick[0]) + "" + nick[1:]
|
||||
}
|
||||
9
contrib/remotenickformat.tengo
Normal file
9
contrib/remotenickformat.tengo
Normal file
@@ -0,0 +1,9 @@
|
||||
/*
|
||||
This script will return the current time in kitchen format if the protocol (of the remote bridge) isn't irc
|
||||
See https://github.com/d5/tengo/blob/master/docs/stdlib-times.md
|
||||
This result can be used in {TENGO} in RemoteNickFormat
|
||||
*/
|
||||
times := import("times")
|
||||
if protocol != "irc" {
|
||||
result=times.time_format(times.now(),times.format_kitchen)
|
||||
}
|
||||
9
docker/arm/Dockerfile
Normal file
9
docker/arm/Dockerfile
Normal file
@@ -0,0 +1,9 @@
|
||||
FROM alpine:edge as certs
|
||||
RUN apk --update add ca-certificates
|
||||
|
||||
FROM scratch
|
||||
ARG VERSION=1.12.3
|
||||
COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||
ADD https://github.com/42wim/matterbridge/releases/download/v${VERSION}/matterbridge-linux-arm /bin/matterbridge
|
||||
RUN chmod +x /bin/matterbridge
|
||||
ENTRYPOINT ["/bin/matterbridge"]
|
||||
5
gateway/bench.tengo
Normal file
5
gateway/bench.tengo
Normal file
@@ -0,0 +1,5 @@
|
||||
text := import("text")
|
||||
if text.re_match("blah",msgText) {
|
||||
msgText="replaced by this"
|
||||
msgUsername="fakeuser"
|
||||
}
|
||||
11
gateway/bridgemap/api.go
Normal file
11
gateway/bridgemap/api.go
Normal file
@@ -0,0 +1,11 @@
|
||||
// +build !noapi
|
||||
|
||||
package bridgemap
|
||||
|
||||
import (
|
||||
"github.com/42wim/matterbridge/bridge/api"
|
||||
)
|
||||
|
||||
func init() {
|
||||
FullMap["api"] = api.New
|
||||
}
|
||||
12
gateway/bridgemap/bdiscord.go
Normal file
12
gateway/bridgemap/bdiscord.go
Normal file
@@ -0,0 +1,12 @@
|
||||
// +build !nodiscord
|
||||
|
||||
package bridgemap
|
||||
|
||||
import (
|
||||
bdiscord "github.com/42wim/matterbridge/bridge/discord"
|
||||
)
|
||||
|
||||
func init() {
|
||||
FullMap["discord"] = bdiscord.New
|
||||
UserTypingSupport["discord"] = struct{}{}
|
||||
}
|
||||
11
gateway/bridgemap/bgitter.go
Normal file
11
gateway/bridgemap/bgitter.go
Normal file
@@ -0,0 +1,11 @@
|
||||
// +build !nogitter
|
||||
|
||||
package bridgemap
|
||||
|
||||
import (
|
||||
bgitter "github.com/42wim/matterbridge/bridge/gitter"
|
||||
)
|
||||
|
||||
func init() {
|
||||
FullMap["gitter"] = bgitter.New
|
||||
}
|
||||
11
gateway/bridgemap/birc.go
Normal file
11
gateway/bridgemap/birc.go
Normal file
@@ -0,0 +1,11 @@
|
||||
// +build !noirc
|
||||
|
||||
package bridgemap
|
||||
|
||||
import (
|
||||
birc "github.com/42wim/matterbridge/bridge/irc"
|
||||
)
|
||||
|
||||
func init() {
|
||||
FullMap["irc"] = birc.New
|
||||
}
|
||||
11
gateway/bridgemap/bkeybase.go
Normal file
11
gateway/bridgemap/bkeybase.go
Normal file
@@ -0,0 +1,11 @@
|
||||
// +build !nokeybase
|
||||
|
||||
package bridgemap
|
||||
|
||||
import (
|
||||
bkeybase "github.com/42wim/matterbridge/bridge/keybase"
|
||||
)
|
||||
|
||||
func init() {
|
||||
FullMap["keybase"] = bkeybase.New
|
||||
}
|
||||
11
gateway/bridgemap/bmatrix.go
Normal file
11
gateway/bridgemap/bmatrix.go
Normal file
@@ -0,0 +1,11 @@
|
||||
// +build !nomatrix
|
||||
|
||||
package bridgemap
|
||||
|
||||
import (
|
||||
bmatrix "github.com/42wim/matterbridge/bridge/matrix"
|
||||
)
|
||||
|
||||
func init() {
|
||||
FullMap["matrix"] = bmatrix.New
|
||||
}
|
||||
11
gateway/bridgemap/bmattermost.go
Normal file
11
gateway/bridgemap/bmattermost.go
Normal file
@@ -0,0 +1,11 @@
|
||||
// +build !nomattermost
|
||||
|
||||
package bridgemap
|
||||
|
||||
import (
|
||||
bmattermost "github.com/42wim/matterbridge/bridge/mattermost"
|
||||
)
|
||||
|
||||
func init() {
|
||||
FullMap["mattermost"] = bmattermost.New
|
||||
}
|
||||
11
gateway/bridgemap/bmsteams.go
Normal file
11
gateway/bridgemap/bmsteams.go
Normal file
@@ -0,0 +1,11 @@
|
||||
// +build !nomsteams
|
||||
|
||||
package bridgemap
|
||||
|
||||
import (
|
||||
bmsteams "github.com/42wim/matterbridge/bridge/msteams"
|
||||
)
|
||||
|
||||
func init() {
|
||||
FullMap["msteams"] = bmsteams.New
|
||||
}
|
||||
11
gateway/bridgemap/bmumble.go
Normal file
11
gateway/bridgemap/bmumble.go
Normal file
@@ -0,0 +1,11 @@
|
||||
// +build !nomumble
|
||||
|
||||
package bridgemap
|
||||
|
||||
import (
|
||||
bmumble "github.com/42wim/matterbridge/bridge/mumble"
|
||||
)
|
||||
|
||||
func init() {
|
||||
FullMap["mumble"] = bmumble.New
|
||||
}
|
||||
11
gateway/bridgemap/bnctalk.go
Normal file
11
gateway/bridgemap/bnctalk.go
Normal file
@@ -0,0 +1,11 @@
|
||||
// +build !nonctalk
|
||||
|
||||
package bridgemap
|
||||
|
||||
import (
|
||||
btalk "github.com/42wim/matterbridge/bridge/nctalk"
|
||||
)
|
||||
|
||||
func init() {
|
||||
FullMap["nctalk"] = btalk.New
|
||||
}
|
||||
10
gateway/bridgemap/bridgemap.go
Normal file
10
gateway/bridgemap/bridgemap.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package bridgemap
|
||||
|
||||
import (
|
||||
"github.com/42wim/matterbridge/bridge"
|
||||
)
|
||||
|
||||
var (
|
||||
FullMap = map[string]bridge.Factory{}
|
||||
UserTypingSupport = map[string]struct{}{}
|
||||
)
|
||||
11
gateway/bridgemap/brocketchat.go
Normal file
11
gateway/bridgemap/brocketchat.go
Normal file
@@ -0,0 +1,11 @@
|
||||
// +build !norocketchat
|
||||
|
||||
package bridgemap
|
||||
|
||||
import (
|
||||
brocketchat "github.com/42wim/matterbridge/bridge/rocketchat"
|
||||
)
|
||||
|
||||
func init() {
|
||||
FullMap["rocketchat"] = brocketchat.New
|
||||
}
|
||||
13
gateway/bridgemap/bslack.go
Normal file
13
gateway/bridgemap/bslack.go
Normal file
@@ -0,0 +1,13 @@
|
||||
// +build !noslack
|
||||
|
||||
package bridgemap
|
||||
|
||||
import (
|
||||
bslack "github.com/42wim/matterbridge/bridge/slack"
|
||||
)
|
||||
|
||||
func init() {
|
||||
FullMap["slack-legacy"] = bslack.NewLegacy
|
||||
FullMap["slack"] = bslack.New
|
||||
UserTypingSupport["slack"] = struct{}{}
|
||||
}
|
||||
11
gateway/bridgemap/bsshchat.go
Normal file
11
gateway/bridgemap/bsshchat.go
Normal file
@@ -0,0 +1,11 @@
|
||||
// +build !nosshchat
|
||||
|
||||
package bridgemap
|
||||
|
||||
import (
|
||||
bsshchat "github.com/42wim/matterbridge/bridge/sshchat"
|
||||
)
|
||||
|
||||
func init() {
|
||||
FullMap["sshchat"] = bsshchat.New
|
||||
}
|
||||
11
gateway/bridgemap/bsteam.go
Normal file
11
gateway/bridgemap/bsteam.go
Normal file
@@ -0,0 +1,11 @@
|
||||
// +build !nosteam
|
||||
|
||||
package bridgemap
|
||||
|
||||
import (
|
||||
bsteam "github.com/42wim/matterbridge/bridge/steam"
|
||||
)
|
||||
|
||||
func init() {
|
||||
FullMap["steam"] = bsteam.New
|
||||
}
|
||||
11
gateway/bridgemap/btelegram.go
Normal file
11
gateway/bridgemap/btelegram.go
Normal file
@@ -0,0 +1,11 @@
|
||||
// +build !notelegram
|
||||
|
||||
package bridgemap
|
||||
|
||||
import (
|
||||
btelegram "github.com/42wim/matterbridge/bridge/telegram"
|
||||
)
|
||||
|
||||
func init() {
|
||||
FullMap["telegram"] = btelegram.New
|
||||
}
|
||||
11
gateway/bridgemap/bvk.go
Normal file
11
gateway/bridgemap/bvk.go
Normal file
@@ -0,0 +1,11 @@
|
||||
// +build !novk
|
||||
|
||||
package bridgemap
|
||||
|
||||
import (
|
||||
bvk "github.com/42wim/matterbridge/bridge/vk"
|
||||
)
|
||||
|
||||
func init() {
|
||||
FullMap["vk"] = bvk.New
|
||||
}
|
||||
11
gateway/bridgemap/bwhatsapp.go
Normal file
11
gateway/bridgemap/bwhatsapp.go
Normal file
@@ -0,0 +1,11 @@
|
||||
// +build !nowhatsapp
|
||||
|
||||
package bridgemap
|
||||
|
||||
import (
|
||||
bwhatsapp "github.com/42wim/matterbridge/bridge/whatsapp"
|
||||
)
|
||||
|
||||
func init() {
|
||||
FullMap["whatsapp"] = bwhatsapp.New
|
||||
}
|
||||
11
gateway/bridgemap/bxmpp.go
Normal file
11
gateway/bridgemap/bxmpp.go
Normal file
@@ -0,0 +1,11 @@
|
||||
// +build !noxmpp
|
||||
|
||||
package bridgemap
|
||||
|
||||
import (
|
||||
bxmpp "github.com/42wim/matterbridge/bridge/xmpp"
|
||||
)
|
||||
|
||||
func init() {
|
||||
FullMap["xmpp"] = bxmpp.New
|
||||
}
|
||||
11
gateway/bridgemap/bzulip.go
Normal file
11
gateway/bridgemap/bzulip.go
Normal file
@@ -0,0 +1,11 @@
|
||||
// +build !nozulip
|
||||
|
||||
package bridgemap
|
||||
|
||||
import (
|
||||
bzulip "github.com/42wim/matterbridge/bridge/zulip"
|
||||
)
|
||||
|
||||
func init() {
|
||||
FullMap["zulip"] = bzulip.New
|
||||
}
|
||||
@@ -2,19 +2,25 @@ package gateway
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/42wim/matterbridge/bridge"
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
log "github.com/Sirupsen/logrus"
|
||||
// "github.com/davecgh/go-spew/spew"
|
||||
"github.com/hashicorp/golang-lru"
|
||||
"github.com/peterhellberg/emojilib"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/42wim/matterbridge/bridge"
|
||||
"github.com/42wim/matterbridge/bridge/config"
|
||||
"github.com/42wim/matterbridge/internal"
|
||||
"github.com/d5/tengo/v2"
|
||||
"github.com/d5/tengo/v2/stdlib"
|
||||
lru "github.com/hashicorp/golang-lru"
|
||||
"github.com/matterbridge/emoji"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type Gateway struct {
|
||||
*config.Config
|
||||
config.Config
|
||||
|
||||
Router *Router
|
||||
MyConfig *config.Gateway
|
||||
Bridges map[string]*bridge.Bridge
|
||||
@@ -23,37 +29,105 @@ type Gateway struct {
|
||||
Message chan config.Message
|
||||
Name string
|
||||
Messages *lru.Cache
|
||||
|
||||
logger *logrus.Entry
|
||||
}
|
||||
|
||||
type BrMsgID struct {
|
||||
br *bridge.Bridge
|
||||
ID string
|
||||
br *bridge.Bridge
|
||||
ID string
|
||||
ChannelID string
|
||||
}
|
||||
|
||||
func New(cfg config.Gateway, r *Router) *Gateway {
|
||||
gw := &Gateway{Channels: make(map[string]*config.ChannelInfo), Message: r.Message,
|
||||
Router: r, Bridges: make(map[string]*bridge.Bridge), Config: r.Config}
|
||||
const apiProtocol = "api"
|
||||
|
||||
// New creates a new Gateway object associated with the specified router and
|
||||
// following the given configuration.
|
||||
func New(rootLogger *logrus.Logger, cfg *config.Gateway, r *Router) *Gateway {
|
||||
logger := rootLogger.WithFields(logrus.Fields{"prefix": "gateway"})
|
||||
|
||||
cache, _ := lru.New(5000)
|
||||
gw.Messages = cache
|
||||
gw.AddConfig(&cfg)
|
||||
gw := &Gateway{
|
||||
Channels: make(map[string]*config.ChannelInfo),
|
||||
Message: r.Message,
|
||||
Router: r,
|
||||
Bridges: make(map[string]*bridge.Bridge),
|
||||
Config: r.Config,
|
||||
Messages: cache,
|
||||
logger: logger,
|
||||
}
|
||||
if err := gw.AddConfig(cfg); err != nil {
|
||||
logger.Errorf("Failed to add configuration to gateway: %#v", err)
|
||||
}
|
||||
return gw
|
||||
}
|
||||
|
||||
// FindCanonicalMsgID returns the ID under which a message was stored in the cache.
|
||||
func (gw *Gateway) FindCanonicalMsgID(protocol string, mID string) string {
|
||||
ID := protocol + " " + mID
|
||||
if gw.Messages.Contains(ID) {
|
||||
return mID
|
||||
}
|
||||
|
||||
// If not keyed, iterate through cache for downstream, and infer upstream.
|
||||
for _, mid := range gw.Messages.Keys() {
|
||||
v, _ := gw.Messages.Peek(mid)
|
||||
ids := v.([]*BrMsgID)
|
||||
for _, downstreamMsgObj := range ids {
|
||||
if ID == downstreamMsgObj.ID {
|
||||
return strings.Replace(mid.(string), protocol+" ", "", 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// AddBridge sets up a new bridge in the gateway object with the specified configuration.
|
||||
func (gw *Gateway) AddBridge(cfg *config.Bridge) error {
|
||||
br := gw.Router.getBridge(cfg.Account)
|
||||
if br == nil {
|
||||
br = bridge.New(gw.Config, cfg, gw.Message)
|
||||
gw.checkConfig(cfg)
|
||||
br = bridge.New(cfg)
|
||||
br.Config = gw.Router.Config
|
||||
br.General = &gw.BridgeValues().General
|
||||
br.Log = gw.logger.WithFields(logrus.Fields{"prefix": br.Protocol})
|
||||
brconfig := &bridge.Config{
|
||||
Remote: gw.Message,
|
||||
Bridge: br,
|
||||
}
|
||||
// add the actual bridger for this protocol to this bridge using the bridgeMap
|
||||
if _, ok := gw.Router.BridgeMap[br.Protocol]; !ok {
|
||||
gw.logger.Fatalf("Incorrect protocol %s specified in gateway configuration %s, exiting.", br.Protocol, cfg.Account)
|
||||
}
|
||||
br.Bridger = gw.Router.BridgeMap[br.Protocol](brconfig)
|
||||
}
|
||||
gw.mapChannelsToBridge(br)
|
||||
gw.Bridges[cfg.Account] = br
|
||||
return nil
|
||||
}
|
||||
|
||||
func (gw *Gateway) checkConfig(cfg *config.Bridge) {
|
||||
match := false
|
||||
for _, key := range gw.Router.Config.Viper().AllKeys() {
|
||||
if strings.HasPrefix(key, strings.ToLower(cfg.Account)) {
|
||||
match = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !match {
|
||||
gw.logger.Fatalf("Account %s defined in gateway %s but no configuration found, exiting.", cfg.Account, gw.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// AddConfig associates a new configuration with the gateway object.
|
||||
func (gw *Gateway) AddConfig(cfg *config.Gateway) error {
|
||||
gw.Name = cfg.Name
|
||||
gw.MyConfig = cfg
|
||||
gw.mapChannels()
|
||||
if err := gw.mapChannels(); err != nil {
|
||||
gw.logger.Errorf("mapChannels() failed: %s", err)
|
||||
}
|
||||
for _, br := range append(gw.MyConfig.In, append(gw.MyConfig.InOut, gw.MyConfig.Out...)...) {
|
||||
br := br //scopelint
|
||||
err := gw.AddBridge(&br)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -71,29 +145,51 @@ func (gw *Gateway) mapChannelsToBridge(br *bridge.Bridge) {
|
||||
}
|
||||
|
||||
func (gw *Gateway) reconnectBridge(br *bridge.Bridge) {
|
||||
br.Disconnect()
|
||||
if err := br.Disconnect(); err != nil {
|
||||
gw.logger.Errorf("Disconnect() %s failed: %s", br.Account, err)
|
||||
}
|
||||
time.Sleep(time.Second * 5)
|
||||
RECONNECT:
|
||||
log.Infof("Reconnecting %s", br.Account)
|
||||
gw.logger.Infof("Reconnecting %s", br.Account)
|
||||
err := br.Connect()
|
||||
if err != nil {
|
||||
log.Errorf("Reconnection failed: %s. Trying again in 60 seconds", err)
|
||||
gw.logger.Errorf("Reconnection failed: %s. Trying again in 60 seconds", err)
|
||||
time.Sleep(time.Second * 60)
|
||||
goto RECONNECT
|
||||
}
|
||||
br.Joined = make(map[string]bool)
|
||||
br.JoinChannels()
|
||||
if err := br.JoinChannels(); err != nil {
|
||||
gw.logger.Errorf("JoinChannels() %s failed: %s", br.Account, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (gw *Gateway) mapChannelConfig(cfg []config.Bridge, direction string) {
|
||||
for _, br := range cfg {
|
||||
if isApi(br.Account) {
|
||||
br.Channel = "api"
|
||||
if isAPI(br.Account) {
|
||||
br.Channel = apiProtocol
|
||||
}
|
||||
// make sure to lowercase irc channels in config #348
|
||||
if strings.HasPrefix(br.Account, "irc.") {
|
||||
br.Channel = strings.ToLower(br.Channel)
|
||||
}
|
||||
if strings.HasPrefix(br.Account, "mattermost.") && strings.HasPrefix(br.Channel, "#") {
|
||||
gw.logger.Errorf("Mattermost channels do not start with a #: remove the # in %s", br.Channel)
|
||||
os.Exit(1)
|
||||
}
|
||||
if strings.HasPrefix(br.Account, "zulip.") && !strings.Contains(br.Channel, "/topic:") {
|
||||
gw.logger.Errorf("Breaking change, since matterbridge 1.14.0 zulip channels need to specify the topic with channel/topic:mytopic in %s of %s", br.Channel, br.Account)
|
||||
os.Exit(1)
|
||||
}
|
||||
ID := br.Channel + br.Account
|
||||
if _, ok := gw.Channels[ID]; !ok {
|
||||
channel := &config.ChannelInfo{Name: br.Channel, Direction: direction, ID: ID, Options: br.Options, Account: br.Account,
|
||||
SameChannel: make(map[string]bool)}
|
||||
channel := &config.ChannelInfo{
|
||||
Name: br.Channel,
|
||||
Direction: direction,
|
||||
ID: ID,
|
||||
Options: br.Options,
|
||||
Account: br.Account,
|
||||
SameChannel: make(map[string]bool),
|
||||
}
|
||||
channel.SameChannel[gw.Name] = br.SameChannel
|
||||
gw.Channels[channel.ID] = channel
|
||||
} else {
|
||||
@@ -115,10 +211,27 @@ func (gw *Gateway) mapChannels() error {
|
||||
|
||||
func (gw *Gateway) getDestChannel(msg *config.Message, dest bridge.Bridge) []config.ChannelInfo {
|
||||
var channels []config.ChannelInfo
|
||||
|
||||
// for messages received from the api check that the gateway is the specified one
|
||||
if msg.Protocol == apiProtocol && gw.Name != msg.Gateway {
|
||||
return channels
|
||||
}
|
||||
|
||||
// discord join/leave is for the whole bridge, isn't a per channel join/leave
|
||||
if msg.Event == config.EventJoinLeave && getProtocol(msg) == "discord" && msg.Channel == "" {
|
||||
for _, channel := range gw.Channels {
|
||||
if channel.Account == dest.Account && strings.Contains(channel.Direction, "out") &&
|
||||
gw.validGatewayDest(msg) {
|
||||
channels = append(channels, *channel)
|
||||
}
|
||||
}
|
||||
return channels
|
||||
}
|
||||
|
||||
// if source channel is in only, do nothing
|
||||
for _, channel := range gw.Channels {
|
||||
// lookup the channel from the message
|
||||
if channel.ID == getChannelID(*msg) {
|
||||
if channel.ID == getChannelID(msg) {
|
||||
// we only have destinations if the original message is from an "in" (sending) channel
|
||||
if !strings.Contains(channel.Direction, "in") {
|
||||
return channels
|
||||
@@ -127,7 +240,7 @@ func (gw *Gateway) getDestChannel(msg *config.Message, dest bridge.Bridge) []con
|
||||
}
|
||||
}
|
||||
for _, channel := range gw.Channels {
|
||||
if _, ok := gw.Channels[getChannelID(*msg)]; !ok {
|
||||
if _, ok := gw.Channels[getChannelID(msg)]; !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -138,70 +251,44 @@ func (gw *Gateway) getDestChannel(msg *config.Message, dest bridge.Bridge) []con
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.Contains(channel.Direction, "out") && channel.Account == dest.Account && gw.validGatewayDest(msg, channel) {
|
||||
if strings.Contains(channel.Direction, "out") && channel.Account == dest.Account && gw.validGatewayDest(msg) {
|
||||
channels = append(channels, *channel)
|
||||
}
|
||||
}
|
||||
return channels
|
||||
}
|
||||
|
||||
func (gw *Gateway) handleMessage(msg config.Message, dest *bridge.Bridge) []*BrMsgID {
|
||||
var brMsgIDs []*BrMsgID
|
||||
func (gw *Gateway) getDestMsgID(msgID string, dest *bridge.Bridge, channel *config.ChannelInfo) string {
|
||||
if res, ok := gw.Messages.Get(msgID); ok {
|
||||
IDs := res.([]*BrMsgID)
|
||||
for _, id := range IDs {
|
||||
// check protocol, bridge name and channelname
|
||||
// for people that reuse the same bridge multiple times. see #342
|
||||
if dest.Protocol == id.br.Protocol && dest.Name == id.br.Name && channel.ID == id.ChannelID {
|
||||
return strings.Replace(id.ID, dest.Protocol+" ", "", 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// TODO refactor
|
||||
// only slack now, check will have to be done in the different bridges.
|
||||
// we need to check if we can't use fallback or text in other bridges
|
||||
if msg.Extra != nil {
|
||||
if dest.Protocol != "slack" {
|
||||
if msg.Text == "" {
|
||||
return brMsgIDs
|
||||
}
|
||||
}
|
||||
// ignoreTextEmpty returns true if we need to ignore a message with an empty text.
|
||||
func (gw *Gateway) ignoreTextEmpty(msg *config.Message) bool {
|
||||
if msg.Text != "" {
|
||||
return false
|
||||
}
|
||||
// only relay join/part when configged
|
||||
if msg.Event == config.EVENT_JOIN_LEAVE && !gw.Bridges[dest.Account].Config.ShowJoinPart {
|
||||
return brMsgIDs
|
||||
if msg.Event == config.EventUserTyping {
|
||||
return false
|
||||
}
|
||||
// broadcast to every out channel (irc QUIT)
|
||||
if msg.Channel == "" && msg.Event != config.EVENT_JOIN_LEAVE {
|
||||
log.Debug("empty channel")
|
||||
return brMsgIDs
|
||||
// we have an attachment or actual bytes, do not ignore
|
||||
if msg.Extra != nil &&
|
||||
(msg.Extra["attachments"] != nil ||
|
||||
len(msg.Extra["file"]) > 0 ||
|
||||
len(msg.Extra[config.EventFileFailureSize]) > 0) {
|
||||
return false
|
||||
}
|
||||
originchannel := msg.Channel
|
||||
origmsg := msg
|
||||
channels := gw.getDestChannel(&msg, *dest)
|
||||
for _, channel := range channels {
|
||||
// do not send to ourself
|
||||
if channel.ID == getChannelID(origmsg) {
|
||||
continue
|
||||
}
|
||||
log.Debugf("Sending %#v from %s (%s) to %s (%s)", msg, msg.Account, originchannel, dest.Account, channel.Name)
|
||||
msg.Channel = channel.Name
|
||||
msg.Avatar = gw.modifyAvatar(origmsg, dest)
|
||||
msg.Username = gw.modifyUsername(origmsg, dest)
|
||||
msg.ID = ""
|
||||
if res, ok := gw.Messages.Get(origmsg.ID); ok {
|
||||
IDs := res.([]*BrMsgID)
|
||||
for _, id := range IDs {
|
||||
if dest.Protocol == id.br.Protocol {
|
||||
msg.ID = id.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
// for api we need originchannel as channel
|
||||
if dest.Protocol == "api" {
|
||||
msg.Channel = originchannel
|
||||
}
|
||||
mID, err := dest.Send(msg)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
// append the message ID (mID) from this bridge (dest) to our brMsgIDs slice
|
||||
if mID != "" {
|
||||
brMsgIDs = append(brMsgIDs, &BrMsgID{dest, mID})
|
||||
}
|
||||
}
|
||||
return brMsgIDs
|
||||
gw.logger.Debugf("ignoring empty message %#v from %s", msg, msg.Account)
|
||||
return true
|
||||
}
|
||||
|
||||
func (gw *Gateway) ignoreMessage(msg *config.Message) bool {
|
||||
@@ -209,44 +296,37 @@ func (gw *Gateway) ignoreMessage(msg *config.Message) bool {
|
||||
if _, ok := gw.Bridges[msg.Account]; !ok {
|
||||
return true
|
||||
}
|
||||
if msg.Text == "" {
|
||||
// we have an attachment
|
||||
if msg.Extra != nil && msg.Extra["attachments"] != nil {
|
||||
return false
|
||||
}
|
||||
log.Debugf("ignoring empty message %#v from %s", msg, msg.Account)
|
||||
|
||||
igNicks := strings.Fields(gw.Bridges[msg.Account].GetString("IgnoreNicks"))
|
||||
igMessages := strings.Fields(gw.Bridges[msg.Account].GetString("IgnoreMessages"))
|
||||
if gw.ignoreTextEmpty(msg) || gw.ignoreText(msg.Username, igNicks) || gw.ignoreText(msg.Text, igMessages) {
|
||||
return true
|
||||
}
|
||||
for _, entry := range strings.Fields(gw.Bridges[msg.Account].Config.IgnoreNicks) {
|
||||
if msg.Username == entry {
|
||||
log.Debugf("ignoring %s from %s", msg.Username, msg.Account)
|
||||
return true
|
||||
}
|
||||
}
|
||||
// TODO do not compile regexps everytime
|
||||
for _, entry := range strings.Fields(gw.Bridges[msg.Account].Config.IgnoreMessages) {
|
||||
if entry != "" {
|
||||
re, err := regexp.Compile(entry)
|
||||
if err != nil {
|
||||
log.Errorf("incorrect regexp %s for %s", entry, msg.Account)
|
||||
continue
|
||||
}
|
||||
if re.MatchString(msg.Text) {
|
||||
log.Debugf("matching %s. ignoring %s from %s", entry, msg.Text, msg.Account)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (gw *Gateway) modifyUsername(msg config.Message, dest *bridge.Bridge) string {
|
||||
br := gw.Bridges[msg.Account]
|
||||
msg.Protocol = br.Protocol
|
||||
nick := gw.Config.General.RemoteNickFormat
|
||||
if nick == "" {
|
||||
nick = dest.Config.RemoteNickFormat
|
||||
func (gw *Gateway) modifyUsername(msg *config.Message, dest *bridge.Bridge) string {
|
||||
if dest.GetBool("StripNick") {
|
||||
re := regexp.MustCompile("[^a-zA-Z0-9]+")
|
||||
msg.Username = re.ReplaceAllString(msg.Username, "")
|
||||
}
|
||||
nick := dest.GetString("RemoteNickFormat")
|
||||
|
||||
// loop to replace nicks
|
||||
br := gw.Bridges[msg.Account]
|
||||
for _, outer := range br.GetStringSlice2D("ReplaceNicks") {
|
||||
search := outer[0]
|
||||
replace := outer[1]
|
||||
// TODO move compile to bridge init somewhere
|
||||
re, err := regexp.Compile(search)
|
||||
if err != nil {
|
||||
gw.logger.Errorf("regexp in %s failed: %s", msg.Account, err)
|
||||
break
|
||||
}
|
||||
msg.Username = re.ReplaceAllString(msg.Username, replace)
|
||||
}
|
||||
|
||||
if len(msg.Username) > 0 {
|
||||
// fix utf-8 issue #193
|
||||
i := 0
|
||||
@@ -257,19 +337,26 @@ func (gw *Gateway) modifyUsername(msg config.Message, dest *bridge.Bridge) strin
|
||||
}
|
||||
i++
|
||||
}
|
||||
nick = strings.Replace(nick, "{NOPINGNICK}", msg.Username[:i]+""+msg.Username[i:], -1)
|
||||
nick = strings.ReplaceAll(nick, "{NOPINGNICK}", msg.Username[:i]+"\u200b"+msg.Username[i:])
|
||||
}
|
||||
nick = strings.Replace(nick, "{NICK}", msg.Username, -1)
|
||||
nick = strings.Replace(nick, "{BRIDGE}", br.Name, -1)
|
||||
nick = strings.Replace(nick, "{PROTOCOL}", br.Protocol, -1)
|
||||
|
||||
nick = strings.ReplaceAll(nick, "{BRIDGE}", br.Name)
|
||||
nick = strings.ReplaceAll(nick, "{PROTOCOL}", br.Protocol)
|
||||
nick = strings.ReplaceAll(nick, "{GATEWAY}", gw.Name)
|
||||
nick = strings.ReplaceAll(nick, "{LABEL}", br.GetString("Label"))
|
||||
nick = strings.ReplaceAll(nick, "{NICK}", msg.Username)
|
||||
nick = strings.ReplaceAll(nick, "{USERID}", msg.UserID)
|
||||
nick = strings.ReplaceAll(nick, "{CHANNEL}", msg.Channel)
|
||||
tengoNick, err := gw.modifyUsernameTengo(msg, br)
|
||||
if err != nil {
|
||||
gw.logger.Errorf("modifyUsernameTengo error: %s", err)
|
||||
}
|
||||
nick = strings.ReplaceAll(nick, "{TENGO}", tengoNick)
|
||||
return nick
|
||||
}
|
||||
|
||||
func (gw *Gateway) modifyAvatar(msg config.Message, dest *bridge.Bridge) string {
|
||||
iconurl := gw.Config.General.IconURL
|
||||
if iconurl == "" {
|
||||
iconurl = dest.Config.IconURL
|
||||
}
|
||||
func (gw *Gateway) modifyAvatar(msg *config.Message, dest *bridge.Bridge) string {
|
||||
iconurl := dest.GetString("IconURL")
|
||||
iconurl = strings.Replace(iconurl, "{NICK}", msg.Username, -1)
|
||||
if msg.Avatar == "" {
|
||||
msg.Avatar = iconurl
|
||||
@@ -278,19 +365,289 @@ func (gw *Gateway) modifyAvatar(msg config.Message, dest *bridge.Bridge) string
|
||||
}
|
||||
|
||||
func (gw *Gateway) modifyMessage(msg *config.Message) {
|
||||
if gw.BridgeValues().General.TengoModifyMessage != "" {
|
||||
gw.logger.Warnf("General TengoModifyMessage=%s is deprecated and will be removed in v1.20.0, please move to Tengo InMessage=%s", gw.BridgeValues().General.TengoModifyMessage, gw.BridgeValues().General.TengoModifyMessage)
|
||||
}
|
||||
|
||||
if err := modifyInMessageTengo(gw.BridgeValues().General.TengoModifyMessage, msg); err != nil {
|
||||
gw.logger.Errorf("TengoModifyMessage failed: %s", err)
|
||||
}
|
||||
|
||||
inMessage := gw.BridgeValues().Tengo.InMessage
|
||||
if inMessage == "" {
|
||||
inMessage = gw.BridgeValues().Tengo.Message
|
||||
if inMessage != "" {
|
||||
gw.logger.Warnf("Tengo Message=%s is deprecated and will be removed in v1.20.0, please move to Tengo InMessage=%s", inMessage, inMessage)
|
||||
}
|
||||
}
|
||||
|
||||
if err := modifyInMessageTengo(inMessage, msg); err != nil {
|
||||
gw.logger.Errorf("Tengo.Message failed: %s", err)
|
||||
}
|
||||
|
||||
// replace :emoji: to unicode
|
||||
msg.Text = emojilib.Replace(msg.Text)
|
||||
msg.Gateway = gw.Name
|
||||
msg.Text = emoji.Sprint(msg.Text)
|
||||
|
||||
br := gw.Bridges[msg.Account]
|
||||
// loop to replace messages
|
||||
for _, outer := range br.GetStringSlice2D("ReplaceMessages") {
|
||||
search := outer[0]
|
||||
replace := outer[1]
|
||||
// TODO move compile to bridge init somewhere
|
||||
re, err := regexp.Compile(search)
|
||||
if err != nil {
|
||||
gw.logger.Errorf("regexp in %s failed: %s", msg.Account, err)
|
||||
break
|
||||
}
|
||||
msg.Text = re.ReplaceAllString(msg.Text, replace)
|
||||
}
|
||||
|
||||
gw.handleExtractNicks(msg)
|
||||
|
||||
// messages from api have Gateway specified, don't overwrite
|
||||
if msg.Protocol != apiProtocol {
|
||||
msg.Gateway = gw.Name
|
||||
}
|
||||
}
|
||||
|
||||
func getChannelID(msg config.Message) string {
|
||||
return msg.Channel + msg.Account
|
||||
// SendMessage sends a message (with specified parentID) to the channel on the selected
|
||||
// destination bridge and returns a message ID or an error.
|
||||
func (gw *Gateway) SendMessage(
|
||||
rmsg *config.Message,
|
||||
dest *bridge.Bridge,
|
||||
channel *config.ChannelInfo,
|
||||
canonicalParentMsgID string,
|
||||
) (string, error) {
|
||||
msg := *rmsg
|
||||
// Only send the avatar download event to ourselves.
|
||||
if msg.Event == config.EventAvatarDownload {
|
||||
if channel.ID != getChannelID(rmsg) {
|
||||
return "", nil
|
||||
}
|
||||
} else {
|
||||
// do not send to ourself for any other event
|
||||
if channel.ID == getChannelID(rmsg) {
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
|
||||
// Only send irc notices to irc
|
||||
if msg.Event == config.EventNoticeIRC && dest.Protocol != "irc" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Too noisy to log like other events
|
||||
debugSendMessage := ""
|
||||
if msg.Event != config.EventUserTyping {
|
||||
debugSendMessage = fmt.Sprintf("=> Sending %#v from %s (%s) to %s (%s)", msg, msg.Account, rmsg.Channel, dest.Account, channel.Name)
|
||||
}
|
||||
|
||||
msg.Channel = channel.Name
|
||||
msg.Avatar = gw.modifyAvatar(rmsg, dest)
|
||||
msg.Username = gw.modifyUsername(rmsg, dest)
|
||||
|
||||
msg.ID = gw.getDestMsgID(rmsg.Protocol+" "+rmsg.ID, dest, channel)
|
||||
|
||||
// for api we need originchannel as channel
|
||||
if dest.Protocol == apiProtocol {
|
||||
msg.Channel = rmsg.Channel
|
||||
}
|
||||
|
||||
msg.ParentID = gw.getDestMsgID(rmsg.Protocol+" "+canonicalParentMsgID, dest, channel)
|
||||
if msg.ParentID == "" {
|
||||
msg.ParentID = canonicalParentMsgID
|
||||
}
|
||||
|
||||
// if the parentID is still empty and we have a parentID set in the original message
|
||||
// this means that we didn't find it in the cache so set it to a "msg-parent-not-found" constant
|
||||
if msg.ParentID == "" && rmsg.ParentID != "" {
|
||||
msg.ParentID = config.ParentIDNotFound
|
||||
}
|
||||
|
||||
drop, err := gw.modifyOutMessageTengo(rmsg, &msg, dest)
|
||||
if err != nil {
|
||||
gw.logger.Errorf("modifySendMessageTengo: %s", err)
|
||||
}
|
||||
|
||||
if drop {
|
||||
gw.logger.Debugf("=> Tengo dropping %#v from %s (%s) to %s (%s)", msg, msg.Account, rmsg.Channel, dest.Account, channel.Name)
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if debugSendMessage != "" {
|
||||
gw.logger.Debug(debugSendMessage)
|
||||
}
|
||||
// if we are using mattermost plugin account, send messages to MattermostPlugin channel
|
||||
// that can be picked up by the mattermost matterbridge plugin
|
||||
if dest.Account == "mattermost.plugin" {
|
||||
gw.Router.MattermostPlugin <- msg
|
||||
}
|
||||
|
||||
defer func(t time.Time) {
|
||||
gw.logger.Debugf("=> Send from %s (%s) to %s (%s) took %s", msg.Account, rmsg.Channel, dest.Account, channel.Name, time.Since(t))
|
||||
}(time.Now())
|
||||
|
||||
mID, err := dest.Send(msg)
|
||||
if err != nil {
|
||||
return mID, err
|
||||
}
|
||||
|
||||
// append the message ID (mID) from this bridge (dest) to our brMsgIDs slice
|
||||
if mID != "" {
|
||||
gw.logger.Debugf("mID %s: %s", dest.Account, mID)
|
||||
return mID, nil
|
||||
//brMsgIDs = append(brMsgIDs, &BrMsgID{dest, dest.Protocol + " " + mID, channel.ID})
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (gw *Gateway) validGatewayDest(msg *config.Message, channel *config.ChannelInfo) bool {
|
||||
func (gw *Gateway) validGatewayDest(msg *config.Message) bool {
|
||||
return msg.Gateway == gw.Name
|
||||
}
|
||||
|
||||
func isApi(account string) bool {
|
||||
func getChannelID(msg *config.Message) string {
|
||||
return msg.Channel + msg.Account
|
||||
}
|
||||
|
||||
func isAPI(account string) bool {
|
||||
return strings.HasPrefix(account, "api.")
|
||||
}
|
||||
|
||||
// ignoreText returns true if text matches any of the input regexes.
|
||||
func (gw *Gateway) ignoreText(text string, input []string) bool {
|
||||
for _, entry := range input {
|
||||
if entry == "" {
|
||||
continue
|
||||
}
|
||||
// TODO do not compile regexps everytime
|
||||
re, err := regexp.Compile(entry)
|
||||
if err != nil {
|
||||
gw.logger.Errorf("incorrect regexp %s", entry)
|
||||
continue
|
||||
}
|
||||
if re.MatchString(text) {
|
||||
gw.logger.Debugf("matching %s. ignoring %s", entry, text)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func getProtocol(msg *config.Message) string {
|
||||
p := strings.Split(msg.Account, ".")
|
||||
return p[0]
|
||||
}
|
||||
|
||||
func modifyInMessageTengo(filename string, msg *config.Message) error {
|
||||
if filename == "" {
|
||||
return nil
|
||||
}
|
||||
res, err := ioutil.ReadFile(filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s := tengo.NewScript(res)
|
||||
s.SetImports(stdlib.GetModuleMap(stdlib.AllModuleNames()...))
|
||||
_ = s.Add("msgText", msg.Text)
|
||||
_ = s.Add("msgUsername", msg.Username)
|
||||
_ = s.Add("msgUserID", msg.UserID)
|
||||
_ = s.Add("msgAccount", msg.Account)
|
||||
_ = s.Add("msgChannel", msg.Channel)
|
||||
c, err := s.Compile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
msg.Text = c.Get("msgText").String()
|
||||
msg.Username = c.Get("msgUsername").String()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (gw *Gateway) modifyUsernameTengo(msg *config.Message, br *bridge.Bridge) (string, error) {
|
||||
filename := gw.BridgeValues().Tengo.RemoteNickFormat
|
||||
if filename == "" {
|
||||
return "", nil
|
||||
}
|
||||
res, err := ioutil.ReadFile(filename)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
s := tengo.NewScript(res)
|
||||
s.SetImports(stdlib.GetModuleMap(stdlib.AllModuleNames()...))
|
||||
_ = s.Add("result", "")
|
||||
_ = s.Add("msgText", msg.Text)
|
||||
_ = s.Add("msgUsername", msg.Username)
|
||||
_ = s.Add("msgUserID", msg.UserID)
|
||||
_ = s.Add("nick", msg.Username)
|
||||
_ = s.Add("msgAccount", msg.Account)
|
||||
_ = s.Add("msgChannel", msg.Channel)
|
||||
_ = s.Add("channel", msg.Channel)
|
||||
_ = s.Add("msgProtocol", msg.Protocol)
|
||||
_ = s.Add("remoteAccount", br.Account)
|
||||
_ = s.Add("protocol", br.Protocol)
|
||||
_ = s.Add("bridge", br.Name)
|
||||
_ = s.Add("gateway", gw.Name)
|
||||
c, err := s.Compile()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := c.Run(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return c.Get("result").String(), nil
|
||||
}
|
||||
|
||||
func (gw *Gateway) modifyOutMessageTengo(origmsg *config.Message, msg *config.Message, br *bridge.Bridge) (bool, error) {
|
||||
filename := gw.BridgeValues().Tengo.OutMessage
|
||||
var (
|
||||
res []byte
|
||||
err error
|
||||
drop bool
|
||||
)
|
||||
|
||||
if filename == "" {
|
||||
res, err = internal.Asset("tengo/outmessage.tengo")
|
||||
if err != nil {
|
||||
return drop, err
|
||||
}
|
||||
} else {
|
||||
res, err = ioutil.ReadFile(filename)
|
||||
if err != nil {
|
||||
return drop, err
|
||||
}
|
||||
}
|
||||
|
||||
s := tengo.NewScript(res)
|
||||
|
||||
s.SetImports(stdlib.GetModuleMap(stdlib.AllModuleNames()...))
|
||||
_ = s.Add("inAccount", origmsg.Account)
|
||||
_ = s.Add("inProtocol", origmsg.Protocol)
|
||||
_ = s.Add("inChannel", origmsg.Channel)
|
||||
_ = s.Add("inGateway", origmsg.Gateway)
|
||||
_ = s.Add("inEvent", origmsg.Event)
|
||||
_ = s.Add("outAccount", br.Account)
|
||||
_ = s.Add("outProtocol", br.Protocol)
|
||||
_ = s.Add("outChannel", msg.Channel)
|
||||
_ = s.Add("outGateway", gw.Name)
|
||||
_ = s.Add("outEvent", msg.Event)
|
||||
_ = s.Add("msgText", msg.Text)
|
||||
_ = s.Add("msgUsername", msg.Username)
|
||||
_ = s.Add("msgUserID", msg.UserID)
|
||||
_ = s.Add("msgDrop", drop)
|
||||
c, err := s.Compile()
|
||||
if err != nil {
|
||||
return drop, err
|
||||
}
|
||||
|
||||
if err := c.Run(); err != nil {
|
||||
return drop, err
|
||||
}
|
||||
|
||||
drop = c.Get("msgDrop").Bool()
|
||||
msg.Text = c.Get("msgText").String()
|
||||
msg.Username = c.Get("msgUsername").String()
|
||||
|
||||
return drop, nil
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user