Coverage for silkaj/wot/exclusions.py: 37%
155 statements
« prev ^ index » next coverage.py v7.6.10, created at 2025-01-20 12:29 +0000
« prev ^ index » next coverage.py v7.6.10, created at 2025-01-20 12:29 +0000
1# Copyright 2016-2025 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/>.
16import logging
17import socket
18import sys
19import time
20import urllib
22import arrow
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
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
37G1_CESIUM_URL = "https://demo.cesium.app/"
38GTEST_CESIUM_URL = "https://g1-test.cesium.app/"
39CESIUM_BLOCK_PATH = "#/app/block/"
41DUNITER_FORUM_URL = "https://forum.duniter.org/"
42MONNAIE_LIBRE_FORUM_URL = "https://forum.monnaie-libre.fr/"
44DUNITER_FORUM_G1_TOPIC_ID = 4393
45DUNITER_FORUM_GTEST_TOPIC_ID = 6554
46MONNAIE_LIBRE_FORUM_G1_TOPIC_ID = 30219 # 26117, 17627, 8233
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?
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 )
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 )
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()
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
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 :]
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
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")
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
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
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"]
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")
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
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 = arrow.get(block.mediantime).shift(hours=1).to(tz="local")
226 info += ".\n- **Exclu·e le** " + dt.format(constants.FULL_HUMAN_FORMAT, locale="fr")
227 info += "\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
236def elements_inbetween_list(i, cert_list):
237 return " " if i == 0 else (" et " if i + 1 == len(cert_list) else ", ")
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)
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
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
276 # discourse_client.close()
277 # How to close this client? It looks like it is not implemented
278 # May be by closing requests' client
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")