Coverage for silkaj/wot/exclusions.py: 37%

155 statements  

« prev     ^ index     » next       coverage.py v7.6.3, created at 2024-10-18 04:28 +0000

1# Copyright 2016-2024 Maël Azimi <m.a@moul.re> 

2# 

3# Silkaj is free software: you can redistribute it and/or modify 

4# it under the terms of the GNU Affero General Public License as published by 

5# the Free Software Foundation, either version 3 of the License, or 

6# (at your option) any later version. 

7# 

8# Silkaj is distributed in the hope that it will be useful, 

9# but WITHOUT ANY WARRANTY; without even the implied warranty of 

10# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

11# GNU Affero General Public License for more details. 

12# 

13# You should have received a copy of the GNU Affero General Public License 

14# along with Silkaj. If not, see <https://www.gnu.org/licenses/>. 

15 

16import logging 

17import socket 

18import sys 

19import time 

20import urllib 

21 

22import pendulum 

23import rich_click as click 

24from duniterpy import constants as dp_const 

25from duniterpy.api.bma import blockchain 

26from duniterpy.api.client import Client 

27from duniterpy.documents.block import Block 

28from pydiscourse import DiscourseClient 

29from pydiscourse.exceptions import DiscourseClientError 

30 

31from silkaj import constants 

32from silkaj.blockchain.tools import get_blockchain_parameters 

33from silkaj.network import client_instance 

34from silkaj.tools import get_currency_symbol 

35from silkaj.wot.tools import wot_lookup 

36 

37G1_CESIUM_URL = "https://demo.cesium.app/" 

38GTEST_CESIUM_URL = "https://g1-test.cesium.app/" 

39CESIUM_BLOCK_PATH = "#/app/block/" 

40 

41DUNITER_FORUM_URL = "https://forum.duniter.org/" 

42MONNAIE_LIBRE_FORUM_URL = "https://forum.monnaie-libre.fr/" 

43 

44DUNITER_FORUM_G1_TOPIC_ID = 4393 

45DUNITER_FORUM_GTEST_TOPIC_ID = 6554 

46MONNAIE_LIBRE_FORUM_G1_TOPIC_ID = 30219 # 26117, 17627, 8233 

47 

48 

49@click.command( 

50 "exclusions", 

51 help="DeathReaper: Generate membership exclusions messages, \ 

52markdown formatted and publish them on Discourse Forums", 

53) 

54@click.option( 

55 "-a", 

56 "--api-id", 

57 help="Username used on Discourse forum API", 

58) 

59@click.option( 

60 "-du", 

61 "--duniter-forum-api-key", 

62 help="API key used on Duniter Forum", 

63) 

64@click.option( 

65 "-ml", 

66 "--ml-forum-api-key", 

67 help="API key used for Monnaie Libre Forum", 

68) 

69@click.argument("days", default=1, type=click.FloatRange(0, 50)) 

70@click.option( 

71 "--publish", 

72 is_flag=True, 

73 help="Publish the messages on the forums, otherwise print them", 

74) 

75def exclusions_command(api_id, duniter_forum_api_key, ml_forum_api_key, days, publish): 

76 params = get_blockchain_parameters() 

77 currency = params["currency"] 

78 check_options(api_id, duniter_forum_api_key, ml_forum_api_key, publish, currency) 

79 bma_client = client_instance() 

80 blocks_to_process = get_blocks_to_process(bma_client, days, params) 

81 if not blocks_to_process: 

82 no_exclusion(days, currency) 

83 message = gen_message_over_blocks(bma_client, blocks_to_process, params) 

84 if not message: 

85 no_exclusion(days, currency) 

86 header = gen_header(blocks_to_process) 

87 # Add ability to publish just one of the two forum, via a flags? 

88 

89 publish_display( 

90 api_id, 

91 duniter_forum_api_key, 

92 header + message, 

93 publish, 

94 currency, 

95 "duniter", 

96 ) 

97 if currency == dp_const.G1_CURRENCY_CODENAME: 

98 publish_display( 

99 api_id, 

100 ml_forum_api_key, 

101 header + message, 

102 publish, 

103 currency, 

104 "monnaielibre", 

105 ) 

106 

107 

108def check_options(api_id, duniter_forum_api_key, ml_forum_api_key, publish, currency): 

109 if publish and ( 

110 not api_id 

111 or not duniter_forum_api_key 

112 or (not ml_forum_api_key and currency != dp_const.G1_TEST_CURRENCY_CODENAME) 

113 ): 

114 sys.exit( 

115 "Error: To be able to publish, api_id, duniter_forum_api, and \ 

116ml_forum_api_key (not required for {constants.GTEST_SYMBOL}) options should be specified", 

117 ) 

118 

119 

120def no_exclusion(days, currency): 

121 # Use Humanize 

122 print(f"No exclusion to report within the last {days} day(s) on {currency}") 

123 # Success exit status for not failing GitLab job in case there is no exclusions 

124 sys.exit() 

125 

126 

127def get_blocks_to_process(bma_client, days, params): 

128 head_number = bma_client(blockchain.current)["number"] 

129 block_number_days_ago = ( 

130 head_number - days * 24 * constants.ONE_HOUR / params["avgGenTime"] 

131 ) 

132 # print(block_number_days_ago) # DEBUG 

133 

134 i = 0 

135 blocks_with_excluded = bma_client(blockchain.excluded)["result"]["blocks"] 

136 for i, block_number in reversed(list(enumerate(blocks_with_excluded))): 

137 if block_number < block_number_days_ago: 

138 index = i 

139 break 

140 return blocks_with_excluded[index + 1 :] 

141 

142 

143def gen_message_over_blocks(bma_client, blocks_to_process, params): 

144 """ 

145 Loop over the list of blocks to retrieve and parse the blocks 

146 Ignore revocation kind of exclusion 

147 """ 

148 if params["currency"] == dp_const.G1_CURRENCY_CODENAME: 

149 es_client = Client(constants.G1_CSP_USER_ENDPOINT) 

150 else: 

151 es_client = Client(constants.GTEST_CSP_USER_ENDPOINT) 

152 message = "" 

153 for block_number in blocks_to_process: 

154 logging.info("Processing block number %s", block_number) 

155 print(f"Processing block number {block_number}") 

156 # DEBUG / to be removed once the #115 logging system is set 

157 

158 try: 

159 block = bma_client(blockchain.block, block_number) 

160 except urllib.error.HTTPError: 

161 time.sleep(2) 

162 block = bma_client(blockchain.block, block_number) 

163 block_hash = block["hash"] 

164 block = Block.from_signed_raw(block["raw"] + block["signature"] + "\n") 

165 

166 if block.revoked and block.excluded[0] == block.revoked[0].pubkey: 

167 continue 

168 message += generate_message(es_client, block, block_hash, params) 

169 return message 

170 

171 

172def gen_header(blocks_to_process): 

173 nbr_exclusions = len(blocks_to_process) 

174 # Handle when there is one block with multiple exclusion within 

175 # And when there is a revocation 

176 s = "s" if nbr_exclusions > 1 else "" 

177 des_du = "des" if nbr_exclusions > 1 else "du" 

178 currency_symbol = get_currency_symbol() 

179 header = f"## Exclusion{s} de la toile de confiance {currency_symbol}, perte{s} {des_du} statut{s} de membre" 

180 message_g1 = "\n> Message automatique. Merci de notifier vos proches de leur exclusion de la toile de confiance." 

181 return header + message_g1 

182 

183 

184def generate_message(es_client, block, block_hash, params): 

185 """ 

186 Loop over exclusions within a block 

187 Generate identity header + info 

188 """ 

189 message = "" 

190 for excluded in block.excluded: 

191 lookup = wot_lookup(excluded)[0] 

192 uid = lookup["uids"][0]["uid"] 

193 

194 pubkey = lookup["pubkey"] 

195 try: 

196 response = es_client.get(f"user/profile/{pubkey}/_source") 

197 es_uid = response["title"] 

198 except (urllib.error.HTTPError, socket.timeout): 

199 es_uid = uid 

200 logging.info("Cesium+ API: Not found pubkey or connection error") 

201 

202 if params["currency"] == dp_const.G1_CURRENCY_CODENAME: 

203 cesium_url = G1_CESIUM_URL 

204 else: 

205 cesium_url = GTEST_CESIUM_URL 

206 cesium_url += CESIUM_BLOCK_PATH 

207 message += f"\n\n### @{uid} [{es_uid}]({cesium_url}{block.number}/{block_hash}?ssl=true)\n" 

208 message += generate_identity_info(lookup, block, params) 

209 return message 

210 

211 

212def generate_identity_info(lookup, block, params): 

213 info = "- **Certifié·e par**" 

214 nbr_different_certifiers = 0 

215 for i, certifier in enumerate(lookup["uids"][0]["others"]): 

216 if certifier["uids"][0] not in info: 

217 nbr_different_certifiers += 1 

218 info += elements_inbetween_list(i, lookup["uids"][0]["others"]) 

219 info += "@" + certifier["uids"][0] 

220 if lookup["signed"]: 

221 info += ".\n- **A certifié**" 

222 for i, certified in enumerate(lookup["signed"]): 

223 info += elements_inbetween_list(i, lookup["signed"]) 

224 info += "@" + certified["uid"] 

225 dt = pendulum.from_timestamp(block.mediantime + constants.ONE_HOUR, tz="local") 

226 info += ".\n- **Exclu·e le** " + dt.format("LLLL", locale="fr") 

227 info += " CET\n- **Raison de l'exclusion** : " 

228 if nbr_different_certifiers < params["sigQty"]: 

229 info += "manque de certifications" 

230 else: 

231 info += "expiration du document d'adhésion" 

232 # a renouveller tous les ans (variable) humanize(params[""]) 

233 return info 

234 

235 

236def elements_inbetween_list(i, cert_list): 

237 return " " if i == 0 else (" et " if i + 1 == len(cert_list) else ", ") 

238 

239 

240def publish_display(api_id, forum_api_key, message, publish, currency, forum): 

241 if publish: 

242 topic_id = get_topic_id(currency, forum) 

243 publish_message_on_the_forum(api_id, forum_api_key, message, topic_id, forum) 

244 elif forum == "duniter": 

245 click.echo(message) 

246 

247 

248def get_topic_id(currency, forum): 

249 if currency == dp_const.G1_CURRENCY_CODENAME: 

250 if forum == "duniter": 

251 return DUNITER_FORUM_G1_TOPIC_ID 

252 return MONNAIE_LIBRE_FORUM_G1_TOPIC_ID 

253 return DUNITER_FORUM_GTEST_TOPIC_ID 

254 

255 

256def publish_message_on_the_forum(api_id, forum_api_key, message, topic_id, forum): 

257 if forum == "duniter": 

258 discourse_client = DiscourseClient( 

259 DUNITER_FORUM_URL, 

260 api_username=api_id, 

261 api_key=forum_api_key, 

262 ) 

263 else: 

264 discourse_client = DiscourseClient( 

265 MONNAIE_LIBRE_FORUM_URL, 

266 api_username=api_id, 

267 api_key=forum_api_key, 

268 ) 

269 try: 

270 response = discourse_client.create_post(message, topic_id=topic_id) 

271 publication_link(forum, response, topic_id) 

272 except DiscourseClientError: 

273 logging.exception("Issue publishing on %s", forum) 

274 # Handle DiscourseClient exceptions, pass them to the logger 

275 

276 # discourse_client.close() 

277 # How to close this client? It looks like it is not implemented 

278 # May be by closing requests' client 

279 

280 

281def publication_link(forum, response, topic_id): 

282 forum_url = DUNITER_FORUM_URL if forum == "duniter" else MONNAIE_LIBRE_FORUM_URL 

283 print(f"Published on {forum_url}t/{response['topic_slug']}/{topic_id!s}/last")