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

Sign up to get free protection for your applications and to get access to all the features.
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