From f6cbe5b3b792c4e8cd2e436f305e08cdb7647f55 Mon Sep 17 00:00:00 2001
From: sanj <67624670+iodrift@users.noreply.github.com>
Date: Sat, 29 Jun 2024 11:58:22 -0700
Subject: [PATCH] Auto-update: Sat Jun 29 11:58:22 PDT 2024

---
 sijapi/__init__.py                 |  13 +--
 sijapi/__main__.py                 |  16 +--
 sijapi/classes.py                  | 156 +++++++++++++++++++++++++++++
 sijapi/config/secrets.yaml-example |   3 +
 sijapi/routers/ig.py               |  42 ++++----
 sijapi/routers/note.py             |  33 +++++-
 sijapi/utilities.py                |  46 +++------
 7 files changed, 238 insertions(+), 71 deletions(-)
 create mode 100644 sijapi/config/secrets.yaml-example

diff --git a/sijapi/__init__.py b/sijapi/__init__.py
index 0406b7d..c5d0f76 100644
--- a/sijapi/__init__.py
+++ b/sijapi/__init__.py
@@ -9,27 +9,22 @@ from dateutil import tz
 from pathlib import Path
 from pydantic import BaseModel
 from typing import List, Optional
-import traceback
-import logging
 from .logs import Logger
-from .classes import AutoResponder, IMAPConfig, SMTPConfig, EmailAccount, EmailContact, IncomingEmail, Database, Geocoder
-
-# from sijapi.config.config import load_config
-# cfg = load_config()
+from .classes import AutoResponder, IMAPConfig, SMTPConfig, EmailAccount, EmailContact, IncomingEmail, Database, Geocoder, APIConfig, Configuration
 
 ### Initial initialization
 BASE_DIR = Path(__file__).resolve().parent
 CONFIG_DIR = BASE_DIR / "config"
 ENV_PATH = CONFIG_DIR / ".env"
 LOGS_DIR = BASE_DIR / "logs"
-
-# Create logger instance
 L = Logger("Central", LOGS_DIR)
-
 os.makedirs(LOGS_DIR, exist_ok=True)
 load_dotenv(ENV_PATH)
 
 ### API essentials
+API_CONFIG_PATH = CONFIG_DIR / "api.yaml"
+SECRETS_CONFIG_PATH = CONFIG_DIR / "secrets.yaml"
+API = APIConfig.load_from_yaml(API_CONFIG_PATH, SECRETS_CONFIG_PATH)
 DB = Database.from_env()
 ROUTERS = os.getenv('ROUTERS', '').split(',')
 PUBLIC_SERVICES = os.getenv('PUBLIC_SERVICES', '').split(',')
diff --git a/sijapi/__main__.py b/sijapi/__main__.py
index 0740f28..8dd5a44 100755
--- a/sijapi/__main__.py
+++ b/sijapi/__main__.py
@@ -18,9 +18,8 @@ from dotenv import load_dotenv
 from pathlib import Path
 from datetime import datetime
 import argparse
-from . import L, LOGS_DIR, OBSIDIAN_VAULT_DIR
+from . import L, API, OBSIDIAN_VAULT_DIR
 from .logs import Logger
-from .utilities import list_and_correct_impermissible_files
 
 parser = argparse.ArgumentParser(description='Personal API.')
 parser.add_argument('--debug', action='store_true', help='Set log level to L.INFO')
@@ -106,6 +105,7 @@ async def handle_exception_middleware(request: Request, call_next):
 
 
 
+
 def load_router(router_name):
     router_file = ROUTER_DIR / f'{router_name}.py'
     L.DEBUG(f"Attempting to load {router_name.capitalize()}...")
@@ -127,15 +127,15 @@ def main(argv):
     else:
         L.CRIT(f"sijapi launched")
         L.CRIT(f"{args._get_args}")
-        for router_name in ROUTERS:
-            load_router(router_name)
-
-    journal = OBSIDIAN_VAULT_DIR / "journal"
-    list_and_correct_impermissible_files(journal, rename=True)
+        for module_name in API.MODULES.__fields__:
+            if getattr(API.MODULES, module_name):
+                load_router(module_name)
+    
     config = Config()
     config.keep_alive_timeout = 1200 
-    config.bind = [HOST]
+    config.bind = [API.BIND]
     asyncio.run(serve(api, config))
 
+
 if __name__ == "__main__":
     main(sys.argv[1:])
\ No newline at end of file
diff --git a/sijapi/classes.py b/sijapi/classes.py
index f22218f..200c35e 100644
--- a/sijapi/classes.py
+++ b/sijapi/classes.py
@@ -3,6 +3,8 @@ from typing import List, Optional, Any, Tuple, Dict, Union, Tuple
 from datetime import datetime, timedelta, timezone
 import asyncio
 import json
+import os
+import re
 import yaml
 import math
 from timezonefinder import TimezoneFinder
@@ -15,6 +17,160 @@ from concurrent.futures import ThreadPoolExecutor
 import reverse_geocoder as rg
 from timezonefinder import TimezoneFinder
 from srtm import get_data
+from pathlib import Path
+import yaml
+from typing import Union, List, TypeVar, Type
+from pydantic import BaseModel, create_model
+
+from pydantic import BaseModel, Field
+from typing import List, Dict
+import yaml
+from pathlib import Path
+import os
+from dotenv import load_dotenv
+
+T = TypeVar('T', bound='Configuration')
+
+class ModulesConfig(BaseModel):
+    asr: bool = Field(alias="asr")
+    calendar: bool = Field(alias="calendar")
+    email: bool = Field(alias="email")
+    health: bool = Field(alias="health")
+    hooks: bool = Field(alias="hooks")
+    llm: bool = Field(alias="llm")
+    locate: bool = Field(alias="locate")
+    note: bool = Field(alias="note")
+    sd: bool = Field(alias="sd")
+    serve: bool = Field(alias="serve")
+    time: bool = Field(alias="time")
+    tts: bool = Field(alias="tts")
+    weather: bool = Field(alias="weather")
+
+
+class APIConfig(BaseModel):
+    BIND: str
+    PORT: int
+    URL: str
+    PUBLIC: List[str]
+    TRUSTED_SUBNETS: List[str]
+    MODULES: ModulesConfig
+    BaseTZ: Optional[str] = 'UTC'
+    KEYS: List[str]
+
+    @classmethod
+    def load_from_yaml(cls, config_path: Path, secrets_path: Path):
+        # Load main configuration
+        with open(config_path, 'r') as file:
+            config_data = yaml.safe_load(file)
+        
+        print(f"Loaded main config: {config_data}")  # Debug print
+        
+        # Load secrets
+        try:
+            with open(secrets_path, 'r') as file:
+                secrets_data = yaml.safe_load(file)
+            print(f"Loaded secrets: {secrets_data}")  # Debug print
+        except FileNotFoundError:
+            print(f"Secrets file not found: {secrets_path}")
+            secrets_data = {}
+        except yaml.YAMLError as e:
+            print(f"Error parsing secrets YAML: {e}")
+            secrets_data = {}
+        
+        # Handle KEYS placeholder
+        if isinstance(config_data.get('KEYS'), list) and len(config_data['KEYS']) == 1:
+            placeholder = config_data['KEYS'][0]
+            if placeholder.startswith('{{') and placeholder.endswith('}}'):
+                key = placeholder[2:-2].strip()  # Remove {{ }} and whitespace
+                parts = key.split('.')
+                if len(parts) == 2 and parts[0] == 'SECRET':
+                    secret_key = parts[1]
+                    if secret_key in secrets_data:
+                        config_data['KEYS'] = secrets_data[secret_key]
+                        print(f"Replaced KEYS with secret: {config_data['KEYS']}")  # Debug print
+                    else:
+                        print(f"Secret key '{secret_key}' not found in secrets file")
+                else:
+                    print(f"Invalid secret placeholder format: {placeholder}")
+        
+        # Convert 'on'/'off' to boolean for MODULES if they are strings
+        for key, value in config_data['MODULES'].items():
+            if isinstance(value, str):
+                config_data['MODULES'][key] = value.lower() == 'on'
+            elif isinstance(value, bool):
+                config_data['MODULES'][key] = value
+            else:
+                raise ValueError(f"Invalid value for module {key}: {value}. Must be 'on', 'off', True, or False.")
+        
+        return cls(**config_data)
+
+class Configuration(BaseModel):
+    @classmethod
+    def load_config(cls: Type[T], yaml_path: Union[str, Path]) -> Union[T, List[T]]:
+        yaml_path = Path(yaml_path)
+        with yaml_path.open('r') as file:
+            config_data = yaml.safe_load(file)
+        
+        # Load environment variables
+        load_dotenv()
+        
+        # Resolve placeholders
+        config_data = cls.resolve_placeholders(config_data)
+        
+        if isinstance(config_data, list):
+            return [cls.create_dynamic_model(**cfg) for cfg in config_data]
+        elif isinstance(config_data, dict):
+            return cls.create_dynamic_model(**config_data)
+        else:
+            raise ValueError(f"Unsupported YAML structure in {yaml_path}")
+
+    @classmethod
+    def resolve_placeholders(cls, data):
+        if isinstance(data, dict):
+            return {k: cls.resolve_placeholders(v) for k, v in data.items()}
+        elif isinstance(data, list):
+            return [cls.resolve_placeholders(v) for v in data]
+        elif isinstance(data, str):
+            return cls.resolve_string_placeholders(data)
+        else:
+            return data
+
+    @classmethod
+    def resolve_string_placeholders(cls, value):
+        pattern = r'\{\{\s*([^}]+)\s*\}\}'
+        matches = re.findall(pattern, value)
+        
+        for match in matches:
+            parts = match.split('.')
+            if len(parts) == 2:
+                category, key = parts
+                if category == 'DIR':
+                    replacement = str(Path(os.getenv(key, '')))
+                elif category == 'SECRET':
+                    replacement = os.getenv(key, '')
+                else:
+                    replacement = os.getenv(match, '')
+                
+                value = value.replace('{{' + match + '}}', replacement)
+        
+        return value
+
+    @classmethod
+    def create_dynamic_model(cls, **data):
+        for key, value in data.items():
+            if isinstance(value, dict):
+                data[key] = cls.create_dynamic_model(**value)
+        
+        DynamicModel = create_model(
+            f'Dynamic{cls.__name__}',
+            __base__=cls,
+            **{k: (type(v), v) for k, v in data.items()}
+        )
+        return DynamicModel(**data)
+
+    class Config:
+        extra = "allow"
+        arbitrary_types_allowed = True
 
 class Location(BaseModel):
     latitude: float
diff --git a/sijapi/config/secrets.yaml-example b/sijapi/config/secrets.yaml-example
new file mode 100644
index 0000000..454ec98
--- /dev/null
+++ b/sijapi/config/secrets.yaml-example
@@ -0,0 +1,3 @@
+GLOBAL_API_KEYS:
+  - sk-YOUR-FIRST-API-KEY
+  - sk-YOUR-SECOND-API-KEY
\ No newline at end of file
diff --git a/sijapi/routers/ig.py b/sijapi/routers/ig.py
index ea37daf..19fcc82 100644
--- a/sijapi/routers/ig.py
+++ b/sijapi/routers/ig.py
@@ -483,15 +483,15 @@ def update_prompt(workflow: dict, post: dict, positive: str, found_key=[None], p
                     for index, item in enumerate(value):
                         update_prompt(item, post, positive, found_key, current_path + [str(index)])
 
-                if value == "API_PPrompt":
+                if value == "API_PrePrompt":
                     workflow[key] = post.get(value, "") + positive
-                    L.DEBUG(f"Updated API_PPrompt to: {workflow[key]}")
-                elif value == "API_SPrompt":
+                    L.DEBUG(f"Updated API_PrePrompt to: {workflow[key]}")
+                elif value == "API_StylePrompt":
                     workflow[key] = post.get(value, "")
-                    L.DEBUG(f"Updated API_SPrompt to: {workflow[key]}")
-                elif value == "API_NPrompt":
+                    L.DEBUG(f"Updated API_StylePrompt to: {workflow[key]}")
+                elif value == "API_NegativePrompt":
                     workflow[key] = post.get(value, "")  
-                    L.DEBUG(f"Updated API_NPrompt to: {workflow[key]}")
+                    L.DEBUG(f"Updated API_NegativePrompt to: {workflow[key]}")
                 elif key == "seed" or key == "noise_seed":
                     workflow[key] = random.randint(1000000000000, 9999999999999)
                     L.DEBUG(f"Updated seed to: {workflow[key]}")
@@ -507,7 +507,7 @@ def update_prompt(workflow: dict, post: dict, positive: str, found_key=[None], p
 
     return found_key[0]
 
-def update_prompt_custom(workflow: dict, API_PPrompt: str, API_SPrompt: str, API_NPrompt: str, found_key=[None], path=None):
+def update_prompt_custom(workflow: dict, API_PrePrompt: str, API_StylePrompt: str, API_NegativePrompt: str, found_key=[None], path=None):
     if path is None:
         path = [] 
 
@@ -519,21 +519,21 @@ def update_prompt_custom(workflow: dict, API_PPrompt: str, API_SPrompt: str, API
                 if isinstance(value, dict):
                     if value.get('class_type') == 'SaveImage' and value.get('inputs', {}).get('filename_prefix') == 'API_':
                         found_key[0] = key
-                    update_prompt(value, API_PPrompt, API_SPrompt, API_NPrompt, found_key, current_path)
+                    update_prompt(value, API_PrePrompt, API_StylePrompt, API_NegativePrompt, found_key, current_path)
                 elif isinstance(value, list):
                     # Recursive call with updated path for each item in a list
                     for index, item in enumerate(value):
-                        update_prompt(item, API_PPrompt, API_SPrompt, API_NPrompt, found_key, current_path + [str(index)])
+                        update_prompt(item, API_PrePrompt, API_StylePrompt, API_NegativePrompt, found_key, current_path + [str(index)])
 
-                if value == "API_PPrompt":
-                    workflow[key] = API_PPrompt
-                    L.DEBUG(f"Updated API_PPrompt to: {workflow[key]}")
-                elif value == "API_SPrompt":
-                    workflow[key] = API_SPrompt
-                    L.DEBUG(f"Updated API_SPrompt to: {workflow[key]}")
-                elif value == "API_NPrompt":
-                    workflow[key] = API_NPrompt
-                    L.DEBUG(f"Updated API_NPrompt to: {workflow[key]}")
+                if value == "API_PrePrompt":
+                    workflow[key] = API_PrePrompt
+                    L.DEBUG(f"Updated API_PrePrompt to: {workflow[key]}")
+                elif value == "API_StylePrompt":
+                    workflow[key] = API_StylePrompt
+                    L.DEBUG(f"Updated API_StylePrompt to: {workflow[key]}")
+                elif value == "API_NegativePrompt":
+                    workflow[key] = API_NegativePrompt
+                    L.DEBUG(f"Updated API_NegativePrompt to: {workflow[key]}")
                 elif key == "seed" or key == "noise_seed":
                     workflow[key] = random.randint(1000000000000, 9999999999999)
                     L.DEBUG(f"Updated seed to: {workflow[key]}")
@@ -682,9 +682,9 @@ def handle_custom_image(custom_post: str):
     else:
         workflow_name = args.workflow if args.workflow else "selfie"
         post = {
-            "API_PPrompt": "",
-            "API_SPrompt": "; (((masterpiece))); (beautiful lighting:1), subdued, fine detail, extremely sharp, 8k, insane detail, dynamic lighting, cinematic, best quality, ultra detailed.",
-            "API_NPrompt": "canvas frame, 3d, ((bad art)), illustrated, deformed, blurry, duplicate, bad art, bad anatomy, worst quality, low quality, watermark, FastNegativeV2, (easynegative:0.5), epiCNegative, easynegative, verybadimagenegative_v1.3",
+            "API_PrePrompt": "",
+            "API_StylePrompt": "; (((masterpiece))); (beautiful lighting:1), subdued, fine detail, extremely sharp, 8k, insane detail, dynamic lighting, cinematic, best quality, ultra detailed.",
+            "API_NegativePrompt": "canvas frame, 3d, ((bad art)), illustrated, deformed, blurry, duplicate, bad art, bad anatomy, worst quality, low quality, watermark, FastNegativeV2, (easynegative:0.5), epiCNegative, easynegative, verybadimagenegative_v1.3",
             "Vision_Prompt": "Write an upbeat Instagram description with emojis to accompany this selfie!",
             "frequency": 2,
             "ghost_tags": [
diff --git a/sijapi/routers/note.py b/sijapi/routers/note.py
index a99170e..a54a063 100644
--- a/sijapi/routers/note.py
+++ b/sijapi/routers/note.py
@@ -32,12 +32,43 @@ from pathlib import Path
 from fastapi import APIRouter, Query, HTTPException
 from sijapi import L, OBSIDIAN_VAULT_DIR, OBSIDIAN_RESOURCES_DIR, ARCHIVE_DIR, BASE_URL, OBSIDIAN_BANNER_SCENE, DEFAULT_11L_VOICE, DEFAULT_VOICE, GEO
 from sijapi.routers import cal, loc, tts, llm, time, sd, weather, asr
-from sijapi.utilities import assemble_journal_path, assemble_archive_path, convert_to_12_hour_format, sanitize_filename, convert_degrees_to_cardinal, HOURLY_COLUMNS_MAPPING
+from sijapi.utilities import assemble_journal_path, assemble_archive_path, convert_to_12_hour_format, sanitize_filename, convert_degrees_to_cardinal, check_file_name, HOURLY_COLUMNS_MAPPING
 from sijapi.classes import Location
 
 note = APIRouter()
 
+def list_and_correct_impermissible_files(root_dir, rename: bool = False):
+    """List and correct all files with impermissible names."""
+    impermissible_files = []
+    for dirpath, _, filenames in os.walk(root_dir):
+        for filename in filenames:
+            if check_file_name(filename):
+                file_path = Path(dirpath) / filename
+                impermissible_files.append(file_path)
+                L.DEBUG(f"Impermissible file found: {file_path}")
+                
+                # Sanitize the file name
+                new_filename = sanitize_filename(filename)
+                new_file_path = Path(dirpath) / new_filename
+                
+                # Ensure the new file name does not already exist
+                if new_file_path.exists():
+                    counter = 1
+                    base_name, ext = os.path.splitext(new_filename)
+                    while new_file_path.exists():
+                        new_filename = f"{base_name}_{counter}{ext}"
+                        new_file_path = Path(dirpath) / new_filename
+                        counter += 1
+                
+                # Rename the file
+                if rename:
+                    os.rename(file_path, new_file_path)
+                    L.DEBUG(f"Renamed: {file_path} -> {new_file_path}")
+    
+    return impermissible_files
 
+journal = OBSIDIAN_VAULT_DIR / "journal"
+list_and_correct_impermissible_files(journal, rename=True)
 
 ### Daily Note Builder ###
 
diff --git a/sijapi/utilities.py b/sijapi/utilities.py
index fe4f670..6a85089 100644
--- a/sijapi/utilities.py
+++ b/sijapi/utilities.py
@@ -6,6 +6,7 @@ import io
 from io import BytesIO
 import base64
 import math
+import paramiko
 from dateutil import parser
 from pathlib import Path
 import filetype
@@ -21,7 +22,6 @@ import pandas as pd
 from scipy.spatial import cKDTree
 from dateutil.parser import parse as dateutil_parse
 from docx import Document
-import asyncpg
 from sshtunnel import SSHTunnelForwarder
 from fastapi import Depends, HTTPException, Request, UploadFile
 from fastapi.security.api_key import APIKeyHeader
@@ -192,37 +192,6 @@ def check_file_name(file_name, max_length=255):
     return needs_sanitization
 
 
-def list_and_correct_impermissible_files(root_dir, rename: bool = False):
-    """List and correct all files with impermissible names."""
-    impermissible_files = []
-    for dirpath, _, filenames in os.walk(root_dir):
-        for filename in filenames:
-            if check_file_name(filename):
-                file_path = Path(dirpath) / filename
-                impermissible_files.append(file_path)
-                L.DEBUG(f"Impermissible file found: {file_path}")
-                
-                # Sanitize the file name
-                new_filename = sanitize_filename(filename)
-                new_file_path = Path(dirpath) / new_filename
-                
-                # Ensure the new file name does not already exist
-                if new_file_path.exists():
-                    counter = 1
-                    base_name, ext = os.path.splitext(new_filename)
-                    while new_file_path.exists():
-                        new_filename = f"{base_name}_{counter}{ext}"
-                        new_file_path = Path(dirpath) / new_filename
-                        counter += 1
-                
-                # Rename the file
-                if rename:
-                    os.rename(file_path, new_file_path)
-                    L.DEBUG(f"Renamed: {file_path} -> {new_file_path}")
-    
-    return impermissible_files
-
-
 def bool_convert(value: str = Form(None)):
     return value.lower() in ["true", "1", "t", "y", "yes"]
 
@@ -473,3 +442,16 @@ def load_geonames_data(path: str):
     
     return data
 
+async def run_ssh_command(server, command):
+    try:
+        ssh = paramiko.SSHClient()
+        ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+        ssh.connect(server.ssh.host, username=server.ssh.user, password=server.ssh.password)
+        stdin, stdout, stderr = ssh.exec_command(command)
+        output = stdout.read().decode()
+        error = stderr.read().decode()
+        ssh.close()
+        return output, error
+    except Exception as e:
+        L.ERR(f"SSH command failed for server {server.id}: {str(e)}")
+        raise
\ No newline at end of file