agentstr 0.1.11__py3-none-any.whl → 0.1.13__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
agentstr/nostr.py CHANGED
@@ -1,51 +1,54 @@
1
+ """
2
+ Core Nostr utilities for agentstr.
3
+ """
4
+
5
+ import asyncio
6
+ import json
1
7
  import logging
2
- from typing import Optional
8
+ from datetime import timedelta
9
+ from pathlib import Path
10
+ from typing import Dict, List, Optional
3
11
 
4
- try:
5
- import asyncio
6
- except ImportError:
7
- raise ImportError(
8
- "`asyncio` not installed. Please install using `pip install asyncio`"
9
- )
12
+ from agentstr.models import MerchantProduct, MerchantStall, NostrProfile
10
13
 
11
14
  try:
12
15
  from nostr_sdk import (
16
+ Alphabet,
13
17
  Client,
14
18
  Coordinate,
15
- Event,
16
19
  EventBuilder,
17
20
  EventId,
21
+ Events,
22
+ Filter,
18
23
  Keys,
19
24
  Kind,
20
25
  Metadata,
21
26
  NostrSigner,
22
27
  ProductData,
23
28
  PublicKey,
24
- ShippingCost,
25
- ShippingMethod,
29
+ SingleLetterTag,
26
30
  StallData,
27
31
  Tag,
28
- Timestamp,
32
+ TagKind,
33
+ TagStandard,
29
34
  )
30
-
31
- except ImportError:
35
+ except ImportError as exc:
32
36
  raise ImportError(
33
37
  "`nostr_sdk` not installed. Please install using `pip install nostr_sdk`"
34
- )
38
+ ) from exc
35
39
 
36
40
 
37
41
  class NostrClient:
38
42
  """
39
- NostrClient implements the set of Nostr utilities required for higher level functions implementing
40
- like the Marketplace.
43
+ NostrClient implements the set of Nostr utilities required for
44
+ higher level functions implementations like the Marketplace.
41
45
 
42
- Nostr is an asynchronous communication protocol. To hide this, NostrClient exposes synchronous functions.
43
- Users of the NostrClient should ignore `_async_` functions which are for internal purposes only.
46
+ Nostr is an asynchronous communication protocol. To hide this,
47
+ NostrClient exposes synchronous functions. Users of the NostrClient
48
+ should ignore `_async_` functions which are for internal purposes only.
44
49
  """
45
50
 
46
51
  logger = logging.getLogger("NostrClient")
47
- ERROR: str = "ERROR"
48
- SUCCESS: str = "SUCCESS"
49
52
 
50
53
  def __init__(
51
54
  self,
@@ -74,6 +77,7 @@ class NostrClient:
74
77
  self.keys = Keys.parse(nsec)
75
78
  self.nostr_signer = NostrSigner.keys(self.keys)
76
79
  self.client = Client(self.nostr_signer)
80
+ self.connected = False
77
81
 
78
82
  def delete_event(self, event_id: EventId, reason: Optional[str] = None) -> EventId:
79
83
  """
@@ -98,7 +102,7 @@ class NostrClient:
98
102
  Publish generic Nostr event to the relay
99
103
 
100
104
  Returns:
101
- EventId: event id if successful or NostrClient.ERROR if unsuccesful
105
+ EventId: event id published
102
106
 
103
107
  Raises:
104
108
  RuntimeError: if the product can't be published
@@ -113,7 +117,7 @@ class NostrClient:
113
117
  text: text to be published as kind 1 event
114
118
 
115
119
  Returns:
116
- EventId: EventId if successful or NostrClient.ERROR if unsuccesful
120
+ EventId: EventId if successful
117
121
 
118
122
  Raises:
119
123
  RuntimeError: if the product can't be published
@@ -121,7 +125,7 @@ class NostrClient:
121
125
  # Run the async publishing function synchronously
122
126
  return asyncio.run(self._async_publish_note(text))
123
127
 
124
- def publish_product(self, product: ProductData) -> EventId:
128
+ def publish_product(self, product: MerchantProduct) -> EventId:
125
129
  """
126
130
  Create or update a NIP-15 Marketplace product with event kind 30018
127
131
 
@@ -129,13 +133,16 @@ class NostrClient:
129
133
  product: product to be published
130
134
 
131
135
  Returns:
132
- EventId: event id if successful or NostrClient.ERROR if unsuccesful
136
+ EventId: event id of the publication event
133
137
 
134
138
  Raises:
135
139
  RuntimeError: if the product can't be published
136
140
  """
137
141
  # Run the async publishing function synchronously
138
- return asyncio.run(self._async_publish_product(product))
142
+ try:
143
+ return asyncio.run(self._async_publish_product(product))
144
+ except Exception as e:
145
+ raise RuntimeError(f"Failed to publish product: {e}") from e
139
146
 
140
147
  def publish_profile(self, name: str, about: str, picture: str) -> EventId:
141
148
  """
@@ -147,7 +154,7 @@ class NostrClient:
147
154
  picture: url to a png file with a picture for the profile
148
155
 
149
156
  Returns:
150
- EventId: event id if successful or NostrClient.ERROR if unsuccesful
157
+ EventId: event id if successful
151
158
 
152
159
  Raises:
153
160
  RuntimeError: if the profile can't be published
@@ -155,20 +162,164 @@ class NostrClient:
155
162
  # Run the async publishing function synchronously
156
163
  return asyncio.run(self._async_publish_profile(name, about, picture))
157
164
 
158
- def publish_stall(self, stall: StallData) -> EventId:
165
+ def publish_stall(self, stall: MerchantStall) -> EventId:
159
166
  """Publish a stall to nostr
160
167
 
161
168
  Args:
162
169
  stall: stall to be published
163
170
 
164
171
  Returns:
165
- EventId: event id if successful or NostrClient.ERROR if unsuccesful
172
+ EventId: Id of the publication event
173
+
174
+ Raises:
175
+ RuntimeError: if the stall can't be published
166
176
  """
167
177
  try:
168
178
  return asyncio.run(self._async_publish_stall(stall))
169
179
  except Exception as e:
170
- self.logger.error(f"Failed to publish stall: {e}")
171
- return NostrClient.ERROR
180
+ raise RuntimeError(f"Failed to publish stall: {e}") from e
181
+
182
+ def retrieve_products_from_seller(self, seller: PublicKey) -> List[MerchantProduct]:
183
+ """
184
+ Retrieve all products from a given seller.
185
+
186
+ Args:
187
+ seller: PublicKey of the seller
188
+
189
+ Returns:
190
+ List[MerchantProduct]: list of products from the seller
191
+ """
192
+ products = []
193
+ try:
194
+ events = asyncio.run(self._async_retrieve_products_from_seller(seller))
195
+ events_list = events.to_vec()
196
+ for event in events_list:
197
+ content = json.loads(event.content())
198
+ product_data = ProductData(
199
+ id=content.get("id"),
200
+ stall_id=content.get("stall_id"),
201
+ name=content.get("name"),
202
+ description=content.get("description"),
203
+ images=content.get("images", []),
204
+ currency=content.get("currency"),
205
+ price=content.get("price"),
206
+ quantity=content.get("quantity"),
207
+ specs=content.get("specs", {}),
208
+ shipping=content.get("shipping", []),
209
+ categories=content.get("categories", []),
210
+ )
211
+ products.append(MerchantProduct.from_product_data(product_data))
212
+ return products
213
+ except Exception as e:
214
+ raise RuntimeError(f"Failed to retrieve products: {e}") from e
215
+
216
+ def retrieve_profile(self, public_key: PublicKey) -> NostrProfile:
217
+ """
218
+ Retrieve a Nostr profile from the relay.
219
+
220
+ Args:
221
+ public_key: bech32 encoded public key of the profile to retrieve
222
+
223
+ Returns:
224
+ NostrProfile: profile of the author
225
+
226
+ Raises:
227
+ RuntimeError: if the profile can't be retrieved
228
+ """
229
+ try:
230
+ return asyncio.run(self._async_retrieve_profile(public_key))
231
+ except Exception as e:
232
+ raise RuntimeError(f"Failed to retrieve profile: {e}") from e
233
+
234
+ def retrieve_sellers(self) -> set[NostrProfile]:
235
+ """
236
+ Retrieve all sellers from the relay.
237
+ Sellers are npubs who have published a stall.
238
+ Return set may be empty if metadata can't be retrieved for any author.
239
+
240
+ Returns:
241
+ set[NostrProfile]: set of seller profiles
242
+ (skips authors with missing metadata)
243
+ """
244
+
245
+ # sellers: set[NostrProfile] = set()
246
+
247
+ # First we retrieve all stalls from the relay
248
+
249
+ try:
250
+ events = asyncio.run(self._async_retrieve_all_stalls())
251
+ except Exception as e:
252
+ raise RuntimeError(f"Failed to retrieve stalls: {e}") from e
253
+
254
+ # Now we search for unique npubs from the list of stalls
255
+
256
+ events_list = events.to_vec()
257
+ authors: Dict[PublicKey, NostrProfile] = {}
258
+
259
+ for event in events_list:
260
+ if event.kind() == Kind(30017):
261
+ # Is this event the first time we see this author?
262
+ if event.author() not in authors:
263
+ # First time we see this author.
264
+ # Let's add the profile to the dictionary
265
+ try:
266
+ profile = asyncio.run(
267
+ self._async_retrieve_profile(event.author())
268
+ )
269
+ # Add profile to the dictionary
270
+ # associated with the author's PublicKey
271
+ authors[event.author()] = profile
272
+ except RuntimeError:
273
+ continue
274
+
275
+ # Now we add locations from the event locations to the profile
276
+
277
+ for tag in event.tags().to_vec():
278
+ standardized_tag = tag.as_standardized()
279
+ if isinstance(standardized_tag, TagStandard.GEOHASH):
280
+ string_repr = str(standardized_tag)
281
+ extracted_geohash = string_repr.split("=")[1].rstrip(
282
+ ")"
283
+ ) # Splitting and removing the closing parenthesis
284
+
285
+ profile = authors[event.author()]
286
+ profile.add_location(extracted_geohash)
287
+ authors[event.author()] = profile
288
+ # else:
289
+ # print(f"Unknown tag: {standardized_tag}")
290
+
291
+ # once we're done iterating over the events, we return the set of profiles
292
+ return set(authors.values())
293
+
294
+ def retrieve_stalls_from_seller(self, seller: PublicKey) -> List[StallData]:
295
+ """
296
+ Retrieve all stalls from a given seller.
297
+
298
+ Args:
299
+ seller: PublicKey of the seller
300
+
301
+ Returns:
302
+ List[StallData]: list of stalls from the seller
303
+
304
+ Raises:
305
+ RuntimeError: if the stalls can't be retrieved
306
+ """
307
+ stalls = []
308
+ try:
309
+ events = asyncio.run(self._async_retrieve_stalls_from_seller(seller))
310
+ events_list = events.to_vec()
311
+ for event in events_list:
312
+ try:
313
+ # Parse the content field instead of the whole event
314
+ content = event.content()
315
+ stall = StallData.from_json(content)
316
+ stalls.append(stall)
317
+ except RuntimeError as e:
318
+ self.logger.warning("Failed to parse stall data: %s", e)
319
+ continue
320
+ return stalls
321
+ except Exception as e:
322
+ raise RuntimeError(f"Failed to retrieve stalls: {e}") from e
172
323
 
173
324
  @classmethod
174
325
  def set_logging_level(cls, logging_level: int) -> None:
@@ -180,29 +331,35 @@ class NostrClient:
180
331
  cls.logger.setLevel(logging_level)
181
332
  for handler in cls.logger.handlers:
182
333
  handler.setLevel(logging_level)
183
- cls.logger.info(f"Logging level set to {logging.getLevelName(logging_level)}")
334
+ cls.logger.info("Logging level set to %s", logging.getLevelName(logging_level))
184
335
 
185
- # ----------------------------------------------------------------------------------------------
186
- # --*-- async functions for internal use only. Developers should use synchronous functions above
187
- # ----------------------------------------------------------------------------------------------
336
+ # ----------------------------------------------------------------
337
+ # internal async functions.
338
+ # Developers should use synchronous functions above
339
+ # ----------------------------------------------------------------
188
340
 
189
- async def _async_connect(self) -> str:
190
- """Asynchronous function to add relay to the NostrClient instance and connect to it.
341
+ async def _async_connect(self) -> None:
342
+ """
343
+ Asynchronous function to add relay to the NostrClient
344
+ instance and connect to it.
191
345
 
192
- Returns:
193
- str: NostrClient.SUCCESS or NostrClient.ERROR
346
+
347
+ Raises:
348
+ RuntimeError: if the relay can't be connected to
194
349
  """
195
- try:
196
- await self.client.add_relay(self.relay)
197
- NostrClient.logger.info(f"Relay {self.relay} succesfully added.")
198
- await self.client.connect()
199
- NostrClient.logger.info("Connected to relay.")
200
- return NostrClient.SUCCESS
201
- except Exception as e:
202
- NostrClient.logger.error(
203
- f"Unable to connect to relay {self.relay}. Exception: {e}."
204
- )
205
- return NostrClient.ERROR
350
+
351
+ if not self.connected:
352
+ try:
353
+ await self.client.add_relay(self.relay)
354
+ NostrClient.logger.info("Relay %s successfully added.", self.relay)
355
+ await self.client.connect()
356
+ await asyncio.sleep(2) # give time for slower connections
357
+ NostrClient.logger.info("Connected to relay.")
358
+ self.connected = True
359
+ except Exception as e:
360
+ raise RuntimeError(
361
+ f"Unable to connect to relay {self.relay}. Exception: {e}."
362
+ ) from e
206
363
 
207
364
  async def _async_publish_event(self, event_builder: EventBuilder) -> EventId:
208
365
  """
@@ -214,27 +371,34 @@ class NostrClient:
214
371
  Raises:
215
372
  RuntimeError: if the event can't be published
216
373
  """
217
- connected = await self._async_connect()
374
+ try:
375
+ await self._async_connect()
218
376
 
219
- if connected == NostrClient.ERROR:
220
- raise RuntimeError("Unable to connect to the relay")
377
+ # Add debug logging
378
+ NostrClient.logger.debug("Attempting to publish event: %s", event_builder)
379
+ NostrClient.logger.debug(
380
+ "Using keys: %s", self.keys.public_key().to_bech32()
381
+ )
221
382
 
222
- try:
383
+ # Wait for connection and try to publish
223
384
  output = await self.client.send_event_builder(event_builder)
224
- if len(output.success) > 0:
225
- NostrClient.logger.info(
226
- f"Event published with event id: {output.id.to_bech32()}"
227
- )
228
- return output.id
229
- else:
230
- raise RuntimeError("Unable to publish event")
231
- except Exception as e:
232
- NostrClient.logger.error(
233
- f"NostrClient instance not properly initialized. Exception: {e}."
234
- )
235
- raise RuntimeError(
236
- f"NostrClient instance not properly initialized. Exception: {e}."
385
+
386
+ # More detailed error handling
387
+ if not output:
388
+ raise RuntimeError("No output received from send_event_builder")
389
+ if len(output.success) == 0:
390
+ reason = getattr(output, "message", "unknown")
391
+ raise RuntimeError(f"Event rejected by relay. Reason: {reason}")
392
+
393
+ NostrClient.logger.info(
394
+ "Event published with event id: %s", output.id.to_bech32()
237
395
  )
396
+ return output.id
397
+
398
+ except Exception as e:
399
+ NostrClient.logger.error("Failed to publish event: %s", str(e))
400
+ NostrClient.logger.debug("Event details:", exc_info=True)
401
+ raise RuntimeError(f"Unable to publish event: {str(e)}") from e
238
402
 
239
403
  async def _async_publish_note(self, text: str) -> EventId:
240
404
  """
@@ -244,7 +408,7 @@ class NostrClient:
244
408
  text: text to be published as kind 1 event
245
409
 
246
410
  Returns:
247
- EventId: event id if successful or NostrClient.ERROR if unsuccesful
411
+ EventId: event id if successful
248
412
 
249
413
  Raises:
250
414
  RuntimeError: if the event can't be published
@@ -252,15 +416,16 @@ class NostrClient:
252
416
  event_builder = EventBuilder.text_note(text)
253
417
  return await self._async_publish_event(event_builder)
254
418
 
255
- async def _async_publish_product(self, product: ProductData) -> EventId:
419
+ async def _async_publish_product(self, product: MerchantProduct) -> EventId:
256
420
  """
257
- Asynchronous function to create or update a NIP-15 Marketplace product with event kind 30018
421
+ Asynchronous function to create or update a NIP-15
422
+ Marketplace product with event kind 30018
258
423
 
259
424
  Args:
260
425
  product: product to publish
261
426
 
262
427
  Returns:
263
- EventId: event id if successful or NostrClient.ERROR if unsuccesfull
428
+ EventId: event id if successful
264
429
 
265
430
  Raises:
266
431
  RuntimeError: if the product can't be published
@@ -271,9 +436,10 @@ class NostrClient:
271
436
 
272
437
  # EventBuilder.product_data() has a bug with tag handling.
273
438
  # We use the function to create the content field and discard the eventbuilder
274
- bad_event_builder = EventBuilder.product_data(product)
439
+ bad_event_builder = EventBuilder.product_data(product.to_product_data())
275
440
 
276
- # create an event from bad_event_builder to extract the content - not broadcasted
441
+ # create an event from bad_event_builder to extract the content -
442
+ # not broadcasted
277
443
  bad_event = await self.client.sign_event_builder(bad_event_builder)
278
444
  content = bad_event.content()
279
445
 
@@ -281,7 +447,7 @@ class NostrClient:
281
447
  good_event_builder = EventBuilder(Kind(30018), content).tags(
282
448
  [Tag.identifier(product.id), Tag.coordinate(coordinate_tag)]
283
449
  )
284
- self.logger.info("Product event: " + str(good_event_builder))
450
+ NostrClient.logger.info("Product event: %s", good_event_builder)
285
451
  return await self._async_publish_event(good_event_builder)
286
452
 
287
453
  async def _async_publish_profile(
@@ -296,7 +462,7 @@ class NostrClient:
296
462
  picture: url to a png file with a picture for the profile
297
463
 
298
464
  Returns:
299
- EventId: event id if successful or NostrClient.ERROR if unsuccesful
465
+ EventId: event id if successful
300
466
 
301
467
  Raises:
302
468
  RuntimeError: if the profile can't be published
@@ -308,20 +474,187 @@ class NostrClient:
308
474
  event_builder = EventBuilder.metadata(metadata_content)
309
475
  return await self._async_publish_event(event_builder)
310
476
 
311
- async def _async_publish_stall(self, stall: StallData) -> EventId:
477
+ async def _async_publish_stall(self, stall: MerchantStall) -> EventId:
312
478
  """
313
- Asynchronous function to create or update a NIP-15 Marketplace stall with event kind 30017
479
+ Asynchronous function to create or update a NIP-15
480
+ Marketplace stall with event kind 30017
314
481
 
315
482
  Args:
316
483
  stall: stall to be published
317
484
 
318
485
  Returns:
319
- EventId: event id if successful or NostrClient.ERROR if unsuccesfull
486
+ EventId: Id of the publication event
320
487
 
321
488
  Raises:
322
489
  RuntimeError: if the profile can't be published
323
490
  """
324
491
 
325
- self.logger.info(f"Stall: {stall}")
326
- event_builder = EventBuilder.stall_data(stall)
492
+ # good_event_builder = EventBuilder(Kind(30018), content).tags(
493
+ # [Tag.identifier(product.id), Tag.coordinate(coordinate_tag)]
494
+ # )
495
+
496
+ NostrClient.logger.info("Merchant Stall: %s", stall)
497
+ event_builder = EventBuilder.stall_data(stall.to_stall_data()).tags(
498
+ [
499
+ Tag.custom(
500
+ TagKind.SINGLE_LETTER(SingleLetterTag.lowercase(Alphabet.G)),
501
+ [stall.geohash],
502
+ ),
503
+ ]
504
+ )
327
505
  return await self._async_publish_event(event_builder)
506
+
507
+ async def _async_retrieve_all_stalls(self) -> Events:
508
+ """
509
+ Asynchronous function to retreive all stalls from a relay
510
+ This function is used internally to find Merchants.
511
+
512
+ Returns:
513
+ Events: events containing all stalls.
514
+
515
+ Raises:
516
+ RuntimeError: if the stalls can't be retrieved
517
+ """
518
+ try:
519
+ await self._async_connect()
520
+ except Exception as e:
521
+ raise RuntimeError("Unable to connect to the relay") from e
522
+
523
+ try:
524
+ events_filter = Filter().kind(Kind(30017))
525
+ events = await self.client.fetch_events_from(
526
+ urls=[self.relay], filter=events_filter, timeout=timedelta(seconds=2)
527
+ )
528
+ return events
529
+ except Exception as e:
530
+ raise RuntimeError(f"Unable to retrieve stalls: {e}") from e
531
+
532
+ async def _async_retrieve_products_from_seller(self, seller: PublicKey) -> Events:
533
+ """
534
+ Asynchronous function to retrieve the products for a given author
535
+
536
+ Args:
537
+ seller: PublicKey of the seller to retrieve the products for
538
+
539
+ Returns:
540
+ Events: list of events containing the products of the seller
541
+
542
+ Raises:
543
+ RuntimeError: if the products can't be retrieved
544
+ """
545
+ try:
546
+ await self._async_connect()
547
+ except Exception as e:
548
+ raise RuntimeError("Unable to connect to the relay") from e
549
+
550
+ try:
551
+ # print(f"Retrieving products from seller: {seller}")
552
+ events_filter = Filter().kind(Kind(30018)).authors([seller])
553
+ events = await self.client.fetch_events_from(
554
+ urls=[self.relay], filter=events_filter, timeout=timedelta(seconds=2)
555
+ )
556
+ return events
557
+ except Exception as e:
558
+ raise RuntimeError(f"Unable to retrieve stalls: {e}") from e
559
+
560
+ async def _async_retrieve_profile(self, author: PublicKey) -> NostrProfile:
561
+ """
562
+ Asynchronous function to retrieve the profile for a given author
563
+
564
+ Args:
565
+ author: PublicKey of the author to retrieve the profile for
566
+
567
+ Returns:
568
+ NostrProfile: profile of the author
569
+
570
+ Raises:
571
+ RuntimeError: if the profile can't be retrieved
572
+ """
573
+ try:
574
+ await self._async_connect()
575
+ except Exception as e:
576
+ raise RuntimeError("Unable to connect to the relay") from e
577
+
578
+ try:
579
+ metadata = await self.client.fetch_metadata(
580
+ public_key=author, timeout=timedelta(seconds=2)
581
+ )
582
+ return NostrProfile.from_metadata(metadata, author)
583
+ except Exception as e:
584
+ raise RuntimeError(f"Unable to retrieve metadata: {e}") from e
585
+
586
+ async def _async_retrieve_stalls_from_seller(self, seller: PublicKey) -> Events:
587
+ """
588
+ Asynchronous function to retrieve the stall for a given author
589
+
590
+ Args:
591
+ seller: PublicKey of the seller to retrieve the stall for
592
+
593
+ Returns:
594
+ Events: list of events containing the stalls of the seller
595
+
596
+ Raises:
597
+ RuntimeError: if the stall can't be retrieved
598
+ """
599
+ try:
600
+ await self._async_connect()
601
+ except Exception as e:
602
+ raise RuntimeError("Unable to connect to the relay") from e
603
+
604
+ try:
605
+ events_filter = Filter().kind(Kind(30017)).authors([seller])
606
+ events = await self.client.fetch_events_from(
607
+ urls=[self.relay], filter=events_filter, timeout=timedelta(seconds=2)
608
+ )
609
+ return events
610
+ except Exception as e:
611
+ raise RuntimeError(f"Unable to retrieve stalls: {e}") from e
612
+
613
+
614
+ def generate_and_save_keys(env_var: str, env_path: Path) -> Keys:
615
+ """Generate new nostr keys and save the private key to .env file.
616
+
617
+ Args:
618
+ env_var: Name of the environment variable to store the key
619
+ env_path: Path to the .env file. If None, looks for .env in current directory
620
+
621
+ Returns:
622
+ The generated Keys object
623
+ """
624
+ # Generate new keys
625
+ keys = Keys.generate()
626
+ nsec = keys.secret_key().to_bech32()
627
+
628
+ # Determine .env path
629
+ if env_path is None:
630
+ env_path = Path.cwd() / ".env"
631
+
632
+ # Read existing .env content
633
+ env_content = ""
634
+ if env_path.exists():
635
+ with open(env_path, "r", encoding="utf-8") as f:
636
+ env_content = f.read()
637
+
638
+ # Check if the env var already exists
639
+ lines = env_content.splitlines()
640
+ new_lines = []
641
+ var_found = False
642
+
643
+ for line in lines:
644
+ if line.startswith(f"{env_var}="):
645
+ new_lines.append(f"{env_var}={nsec}")
646
+ var_found = True
647
+ else:
648
+ new_lines.append(line)
649
+
650
+ # If var wasn't found, add it
651
+ if not var_found:
652
+ new_lines.append(f"{env_var}={nsec}")
653
+
654
+ # Write back to .env
655
+ with open(env_path, "w", encoding="utf-8") as f:
656
+ f.write("\n".join(new_lines))
657
+ if new_lines: # Add final newline if there's content
658
+ f.write("\n")
659
+
660
+ return keys