ai-lls-lib 2.0.0rc2__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.
- ai_lls_lib/__init__.py +32 -0
- ai_lls_lib/auth/__init__.py +4 -0
- ai_lls_lib/auth/context_parser.py +68 -0
- ai_lls_lib/cli/__init__.py +3 -0
- ai_lls_lib/cli/__main__.py +63 -0
- ai_lls_lib/cli/aws_client.py +123 -0
- ai_lls_lib/cli/commands/__init__.py +3 -0
- ai_lls_lib/cli/commands/admin.py +174 -0
- ai_lls_lib/cli/commands/cache.py +142 -0
- ai_lls_lib/cli/commands/monitor.py +297 -0
- ai_lls_lib/cli/commands/stripe.py +355 -0
- ai_lls_lib/cli/commands/test_stack.py +216 -0
- ai_lls_lib/cli/commands/verify.py +204 -0
- ai_lls_lib/cli/env_loader.py +129 -0
- ai_lls_lib/core/__init__.py +3 -0
- ai_lls_lib/core/cache.py +106 -0
- ai_lls_lib/core/models.py +77 -0
- ai_lls_lib/core/processor.py +295 -0
- ai_lls_lib/core/verifier.py +90 -0
- ai_lls_lib/payment/__init__.py +13 -0
- ai_lls_lib/payment/credit_manager.py +186 -0
- ai_lls_lib/payment/models.py +102 -0
- ai_lls_lib/payment/stripe_manager.py +486 -0
- ai_lls_lib/payment/webhook_processor.py +215 -0
- ai_lls_lib/providers/__init__.py +8 -0
- ai_lls_lib/providers/base.py +28 -0
- ai_lls_lib/providers/external.py +151 -0
- ai_lls_lib/providers/stub.py +48 -0
- ai_lls_lib/testing/__init__.py +3 -0
- ai_lls_lib/testing/fixtures.py +104 -0
- ai_lls_lib-2.0.0rc2.dist-info/METADATA +301 -0
- ai_lls_lib-2.0.0rc2.dist-info/RECORD +34 -0
- ai_lls_lib-2.0.0rc2.dist-info/WHEEL +4 -0
- ai_lls_lib-2.0.0rc2.dist-info/entry_points.txt +3 -0
ai_lls_lib/__init__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AI LLS Library - Core business logic for Landline Scrubber.
|
|
3
|
+
|
|
4
|
+
This library provides phone verification and DNC checking capabilities.
|
|
5
|
+
|
|
6
|
+
Version 2.0.0 establishes clean semantic versioning baseline.
|
|
7
|
+
All version management now controlled by Python Semantic Release.
|
|
8
|
+
"""
|
|
9
|
+
from ai_lls_lib.core.models import (
|
|
10
|
+
PhoneVerification,
|
|
11
|
+
BulkJob,
|
|
12
|
+
BulkJobStatus,
|
|
13
|
+
LineType,
|
|
14
|
+
VerificationSource,
|
|
15
|
+
JobStatus
|
|
16
|
+
)
|
|
17
|
+
from ai_lls_lib.core.verifier import PhoneVerifier
|
|
18
|
+
from ai_lls_lib.core.processor import BulkProcessor
|
|
19
|
+
from ai_lls_lib.core.cache import DynamoDBCache
|
|
20
|
+
|
|
21
|
+
__version__ = "2.0.0-rc.2"
|
|
22
|
+
__all__ = [
|
|
23
|
+
"PhoneVerification",
|
|
24
|
+
"BulkJob",
|
|
25
|
+
"BulkJobStatus",
|
|
26
|
+
"LineType",
|
|
27
|
+
"VerificationSource",
|
|
28
|
+
"JobStatus",
|
|
29
|
+
"PhoneVerifier",
|
|
30
|
+
"BulkProcessor",
|
|
31
|
+
"DynamoDBCache",
|
|
32
|
+
]
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Auth context parser for HTTP API v2.0 events."""
|
|
2
|
+
from typing import Any, Dict, Optional
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def get_user_from_event(event: Dict[str, Any]) -> Optional[str]:
|
|
6
|
+
"""
|
|
7
|
+
Extract user ID from HTTP API v2.0 event with all possible paths.
|
|
8
|
+
Handles both JWT and API key authentication contexts.
|
|
9
|
+
|
|
10
|
+
This function handles the complexities of AWS API Gateway authorizer contexts,
|
|
11
|
+
especially when EnableSimpleResponses is set to false, which wraps the
|
|
12
|
+
context in a 'lambda' key.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
event: The Lambda event from API Gateway HTTP API v2.0
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
User ID string if found, None otherwise
|
|
19
|
+
"""
|
|
20
|
+
request_context = event.get("requestContext", {})
|
|
21
|
+
auth = request_context.get("authorizer", {})
|
|
22
|
+
|
|
23
|
+
# Handle lambda-wrapped context (EnableSimpleResponses: false)
|
|
24
|
+
# When EnableSimpleResponses is false, the authorizer context is wrapped
|
|
25
|
+
lam_ctx = auth.get("lambda", auth) if isinstance(auth.get("lambda"), dict) else auth
|
|
26
|
+
|
|
27
|
+
# Try all possible paths for user_id in priority order
|
|
28
|
+
user_id = (
|
|
29
|
+
# Lambda authorizer paths (most common with current setup)
|
|
30
|
+
lam_ctx.get("principal_id") or
|
|
31
|
+
lam_ctx.get("principalId") or
|
|
32
|
+
lam_ctx.get("sub") or
|
|
33
|
+
lam_ctx.get("user_id") or
|
|
34
|
+
# JWT paths (when using JWT authorizer directly)
|
|
35
|
+
auth.get("jwt", {}).get("claims", {}).get("sub") or
|
|
36
|
+
# Direct auth paths (fallback)
|
|
37
|
+
auth.get("principal_id") or
|
|
38
|
+
auth.get("principalId") or
|
|
39
|
+
auth.get("sub")
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
return user_id
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_email_from_event(event: Dict[str, Any]) -> Optional[str]:
|
|
46
|
+
"""
|
|
47
|
+
Extract email from HTTP API v2.0 event.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
event: The Lambda event from API Gateway HTTP API v2.0
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Email string if found, None otherwise
|
|
54
|
+
"""
|
|
55
|
+
request_context = event.get("requestContext", {})
|
|
56
|
+
auth = request_context.get("authorizer", {})
|
|
57
|
+
|
|
58
|
+
# Handle lambda-wrapped context
|
|
59
|
+
lam_ctx = auth.get("lambda", auth) if isinstance(auth.get("lambda"), dict) else auth
|
|
60
|
+
|
|
61
|
+
# Try to get email from various locations
|
|
62
|
+
email = (
|
|
63
|
+
lam_ctx.get("email") or
|
|
64
|
+
auth.get("jwt", {}).get("claims", {}).get("email") or
|
|
65
|
+
auth.get("email")
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
return email
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Landline Scrubber CLI entry point
|
|
3
|
+
"""
|
|
4
|
+
import click
|
|
5
|
+
import sys
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from ai_lls_lib.cli.commands import verify, cache, admin, test_stack, stripe, monitor
|
|
9
|
+
from ai_lls_lib.cli.env_loader import load_env_file
|
|
10
|
+
|
|
11
|
+
def load_env_files(verbose=False):
|
|
12
|
+
"""Load .env files from ~/.lls and current directory"""
|
|
13
|
+
# Load from ~/.lls/.env
|
|
14
|
+
home_env = Path.home() / '.lls' / '.env'
|
|
15
|
+
if home_env.exists():
|
|
16
|
+
env_vars = load_env_file(home_env)
|
|
17
|
+
for key, value in env_vars.items():
|
|
18
|
+
if key not in os.environ: # Don't override existing env vars
|
|
19
|
+
os.environ[key] = value
|
|
20
|
+
if env_vars and verbose:
|
|
21
|
+
click.echo(f"Loaded {len(env_vars)} variables from {home_env}", err=True)
|
|
22
|
+
|
|
23
|
+
# Load from current directory .env
|
|
24
|
+
local_env = Path('.env')
|
|
25
|
+
if local_env.exists():
|
|
26
|
+
env_vars = load_env_file(local_env)
|
|
27
|
+
for key, value in env_vars.items():
|
|
28
|
+
if key not in os.environ: # Don't override existing env vars
|
|
29
|
+
os.environ[key] = value
|
|
30
|
+
if env_vars and verbose:
|
|
31
|
+
click.echo(f"Loaded {len(env_vars)} variables from {local_env}", err=True)
|
|
32
|
+
|
|
33
|
+
# Set default environment to 'prod' if not set
|
|
34
|
+
if 'ENVIRONMENT' not in os.environ:
|
|
35
|
+
os.environ['ENVIRONMENT'] = 'prod'
|
|
36
|
+
if verbose:
|
|
37
|
+
click.echo(f"Set default ENVIRONMENT=prod", err=True)
|
|
38
|
+
|
|
39
|
+
@click.group()
|
|
40
|
+
@click.version_option(version="0.1.0", prog_name="ai-lls")
|
|
41
|
+
def cli():
|
|
42
|
+
"""Landline Scrubber CLI - Administrative and debugging tools"""
|
|
43
|
+
# Load environment files at startup
|
|
44
|
+
load_env_files()
|
|
45
|
+
|
|
46
|
+
# Register command groups
|
|
47
|
+
cli.add_command(verify.verify_group)
|
|
48
|
+
cli.add_command(cache.cache_group)
|
|
49
|
+
cli.add_command(admin.admin_group)
|
|
50
|
+
cli.add_command(test_stack.test_stack_group)
|
|
51
|
+
cli.add_command(stripe.stripe_group)
|
|
52
|
+
cli.add_command(monitor.monitor_group)
|
|
53
|
+
|
|
54
|
+
def main():
|
|
55
|
+
"""Main entry point"""
|
|
56
|
+
try:
|
|
57
|
+
cli()
|
|
58
|
+
except Exception as e:
|
|
59
|
+
click.echo(f"Error: {e}", err=True)
|
|
60
|
+
sys.exit(1)
|
|
61
|
+
|
|
62
|
+
if __name__ == "__main__":
|
|
63
|
+
main()
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AWS client utilities for CLI operations
|
|
3
|
+
"""
|
|
4
|
+
import os
|
|
5
|
+
import boto3
|
|
6
|
+
from typing import Optional, Any, Dict
|
|
7
|
+
from botocore.exceptions import ClientError
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
class AWSClient:
|
|
11
|
+
"""Wrapper for AWS operations with proper error handling"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, region: Optional[str] = None, profile: Optional[str] = None):
|
|
14
|
+
"""Initialize AWS clients"""
|
|
15
|
+
self.region = region or os.environ.get("AWS_REGION", "us-east-1")
|
|
16
|
+
self.profile = profile
|
|
17
|
+
|
|
18
|
+
# Create session
|
|
19
|
+
if profile:
|
|
20
|
+
self.session = boto3.Session(profile_name=profile, region_name=self.region)
|
|
21
|
+
else:
|
|
22
|
+
self.session = boto3.Session(region_name=self.region)
|
|
23
|
+
|
|
24
|
+
# Lazy-load clients
|
|
25
|
+
self._dynamodb = None
|
|
26
|
+
self._s3 = None
|
|
27
|
+
self._sqs = None
|
|
28
|
+
self._secretsmanager = None
|
|
29
|
+
self._cloudformation = None
|
|
30
|
+
self._logs = None
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def dynamodb(self):
|
|
34
|
+
"""Get DynamoDB client"""
|
|
35
|
+
if not self._dynamodb:
|
|
36
|
+
self._dynamodb = self.session.resource('dynamodb')
|
|
37
|
+
return self._dynamodb
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def s3(self):
|
|
41
|
+
"""Get S3 client"""
|
|
42
|
+
if not self._s3:
|
|
43
|
+
self._s3 = self.session.client('s3')
|
|
44
|
+
return self._s3
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def sqs(self):
|
|
48
|
+
"""Get SQS client"""
|
|
49
|
+
if not self._sqs:
|
|
50
|
+
self._sqs = self.session.client('sqs')
|
|
51
|
+
return self._sqs
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def secretsmanager(self):
|
|
55
|
+
"""Get Secrets Manager client"""
|
|
56
|
+
if not self._secretsmanager:
|
|
57
|
+
self._secretsmanager = self.session.client('secretsmanager')
|
|
58
|
+
return self._secretsmanager
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def cloudformation(self):
|
|
62
|
+
"""Get CloudFormation client"""
|
|
63
|
+
if not self._cloudformation:
|
|
64
|
+
self._cloudformation = self.session.client('cloudformation')
|
|
65
|
+
return self._cloudformation
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def logs(self):
|
|
69
|
+
"""Get CloudWatch Logs client"""
|
|
70
|
+
if not self._logs:
|
|
71
|
+
self._logs = self.session.client('logs')
|
|
72
|
+
return self._logs
|
|
73
|
+
|
|
74
|
+
def get_stack_outputs(self, stack_name: str) -> Dict[str, str]:
|
|
75
|
+
"""Get CloudFormation stack outputs"""
|
|
76
|
+
try:
|
|
77
|
+
response = self.cloudformation.describe_stacks(StackName=stack_name)
|
|
78
|
+
stack = response['Stacks'][0]
|
|
79
|
+
outputs = {}
|
|
80
|
+
for output in stack.get('Outputs', []):
|
|
81
|
+
outputs[output['OutputKey']] = output['OutputValue']
|
|
82
|
+
return outputs
|
|
83
|
+
except ClientError as e:
|
|
84
|
+
if e.response['Error']['Code'] == 'ValidationError':
|
|
85
|
+
raise click.ClickException(f"Stack '{stack_name}' not found")
|
|
86
|
+
raise
|
|
87
|
+
|
|
88
|
+
def get_table_name(self, stack_name: str, logical_name: str) -> str:
|
|
89
|
+
"""Get actual table name from stack"""
|
|
90
|
+
try:
|
|
91
|
+
response = self.cloudformation.describe_stack_resource(
|
|
92
|
+
StackName=stack_name,
|
|
93
|
+
LogicalResourceId=logical_name
|
|
94
|
+
)
|
|
95
|
+
return response['StackResourceDetail']['PhysicalResourceId']
|
|
96
|
+
except ClientError:
|
|
97
|
+
# Fallback to conventional naming
|
|
98
|
+
return f"{stack_name}-{logical_name.lower()}"
|
|
99
|
+
|
|
100
|
+
def scan_table(self, table_name: str, limit: int = 100) -> list:
|
|
101
|
+
"""Scan DynamoDB table"""
|
|
102
|
+
try:
|
|
103
|
+
table = self.dynamodb.Table(table_name)
|
|
104
|
+
response = table.scan(Limit=limit)
|
|
105
|
+
return response.get('Items', [])
|
|
106
|
+
except ClientError as e:
|
|
107
|
+
raise click.ClickException(f"Error scanning table: {e}")
|
|
108
|
+
|
|
109
|
+
def put_item(self, table_name: str, item: dict) -> None:
|
|
110
|
+
"""Put item to DynamoDB"""
|
|
111
|
+
try:
|
|
112
|
+
table = self.dynamodb.Table(table_name)
|
|
113
|
+
table.put_item(Item=item)
|
|
114
|
+
except ClientError as e:
|
|
115
|
+
raise click.ClickException(f"Error putting item: {e}")
|
|
116
|
+
|
|
117
|
+
def delete_item(self, table_name: str, key: dict) -> None:
|
|
118
|
+
"""Delete item from DynamoDB"""
|
|
119
|
+
try:
|
|
120
|
+
table = self.dynamodb.Table(table_name)
|
|
121
|
+
table.delete_item(Key=key)
|
|
122
|
+
except ClientError as e:
|
|
123
|
+
raise click.ClickException(f"Error deleting item: {e}")
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Administrative commands for user and credit management
|
|
3
|
+
"""
|
|
4
|
+
import click
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from ai_lls_lib.cli.aws_client import AWSClient
|
|
8
|
+
|
|
9
|
+
@click.group(name="admin")
|
|
10
|
+
def admin_group():
|
|
11
|
+
"""Administrative commands"""
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
@admin_group.command(name="user-credits")
|
|
15
|
+
@click.argument("user_id")
|
|
16
|
+
@click.option("--add", type=int, help="Add credits")
|
|
17
|
+
@click.option("--set", "set_credits", type=int, help="Set credits to specific value")
|
|
18
|
+
@click.option("--stack", default="landline-api", help="CloudFormation stack name")
|
|
19
|
+
@click.option("--profile", help="AWS profile")
|
|
20
|
+
@click.option("--region", help="AWS region")
|
|
21
|
+
def user_credits(user_id, add, set_credits, stack, profile, region):
|
|
22
|
+
"""Manage user credits"""
|
|
23
|
+
aws = AWSClient(region=region, profile=profile)
|
|
24
|
+
credits_table = aws.get_table_name(stack, "CreditsTable")
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
table = aws.dynamodb.Table(credits_table)
|
|
28
|
+
|
|
29
|
+
# Get current credits
|
|
30
|
+
response = table.get_item(Key={"user_id": user_id})
|
|
31
|
+
current = response.get('Item', {}).get('credits', 0)
|
|
32
|
+
|
|
33
|
+
click.echo(f"User {user_id} - Current credits: {current}")
|
|
34
|
+
|
|
35
|
+
if add:
|
|
36
|
+
new_credits = current + add
|
|
37
|
+
table.update_item(
|
|
38
|
+
Key={"user_id": user_id},
|
|
39
|
+
UpdateExpression="SET credits = :val, updated_at = :now",
|
|
40
|
+
ExpressionAttributeValues={
|
|
41
|
+
":val": new_credits,
|
|
42
|
+
":now": datetime.utcnow().isoformat()
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
click.echo(f"Added {add} credits. New balance: {new_credits}")
|
|
46
|
+
|
|
47
|
+
elif set_credits is not None:
|
|
48
|
+
table.update_item(
|
|
49
|
+
Key={"user_id": user_id},
|
|
50
|
+
UpdateExpression="SET credits = :val, updated_at = :now",
|
|
51
|
+
ExpressionAttributeValues={
|
|
52
|
+
":val": set_credits,
|
|
53
|
+
":now": datetime.utcnow().isoformat()
|
|
54
|
+
}
|
|
55
|
+
)
|
|
56
|
+
click.echo(f"Set credits to {set_credits}")
|
|
57
|
+
|
|
58
|
+
except Exception as e:
|
|
59
|
+
click.echo(f"Error managing credits: {e}", err=True)
|
|
60
|
+
|
|
61
|
+
@admin_group.command(name="api-keys")
|
|
62
|
+
@click.option("--user", help="Filter by user ID")
|
|
63
|
+
@click.option("--stack", default="landline-api", help="CloudFormation stack name")
|
|
64
|
+
@click.option("--profile", help="AWS profile")
|
|
65
|
+
@click.option("--region", help="AWS region")
|
|
66
|
+
def list_api_keys(user, stack, profile, region):
|
|
67
|
+
"""List API keys"""
|
|
68
|
+
aws = AWSClient(region=region, profile=profile)
|
|
69
|
+
keys_table = aws.get_table_name(stack, "ApiKeysTable")
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
if user:
|
|
73
|
+
# Query by user using GSI
|
|
74
|
+
table = aws.dynamodb.Table(keys_table)
|
|
75
|
+
response = table.query(
|
|
76
|
+
IndexName='gsi_user',
|
|
77
|
+
KeyConditionExpression='user_id = :uid',
|
|
78
|
+
ExpressionAttributeValues={':uid': user}
|
|
79
|
+
)
|
|
80
|
+
items = response.get('Items', [])
|
|
81
|
+
else:
|
|
82
|
+
# Scan all
|
|
83
|
+
items = aws.scan_table(keys_table)
|
|
84
|
+
|
|
85
|
+
if not items:
|
|
86
|
+
click.echo("No API keys found")
|
|
87
|
+
return
|
|
88
|
+
|
|
89
|
+
click.echo(f"\nAPI Keys ({len(items)} total):")
|
|
90
|
+
click.echo("=" * 60)
|
|
91
|
+
|
|
92
|
+
for item in items:
|
|
93
|
+
click.echo(f"Key ID: {item.get('api_key_id', 'N/A')}")
|
|
94
|
+
click.echo(f"User: {item.get('user_id', 'N/A')}")
|
|
95
|
+
click.echo(f"Created: {item.get('created_at', 'N/A')}")
|
|
96
|
+
click.echo(f"Last Used: {item.get('last_used', 'Never')}")
|
|
97
|
+
click.echo("-" * 60)
|
|
98
|
+
|
|
99
|
+
except Exception as e:
|
|
100
|
+
click.echo(f"Error listing API keys: {e}", err=True)
|
|
101
|
+
|
|
102
|
+
@admin_group.command(name="queue-stats")
|
|
103
|
+
@click.option("--stack", default="landline-api", help="CloudFormation stack name")
|
|
104
|
+
@click.option("--profile", help="AWS profile")
|
|
105
|
+
@click.option("--region", help="AWS region")
|
|
106
|
+
def queue_stats(stack, profile, region):
|
|
107
|
+
"""Show SQS queue statistics"""
|
|
108
|
+
aws = AWSClient(region=region, profile=profile)
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
# Get queue URLs from stack
|
|
112
|
+
outputs = aws.get_stack_outputs(stack)
|
|
113
|
+
|
|
114
|
+
# Find queue URLs or construct them
|
|
115
|
+
queue_name = f"{stack}-bulk-processing"
|
|
116
|
+
dlq_name = f"{stack}-bulk-processing-dlq"
|
|
117
|
+
|
|
118
|
+
# Get queue attributes
|
|
119
|
+
queues = [
|
|
120
|
+
("Processing Queue", queue_name),
|
|
121
|
+
("Dead Letter Queue", dlq_name)
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
for display_name, queue in queues:
|
|
125
|
+
try:
|
|
126
|
+
response = aws.sqs.get_queue_url(QueueName=queue)
|
|
127
|
+
queue_url = response['QueueUrl']
|
|
128
|
+
|
|
129
|
+
attrs = aws.sqs.get_queue_attributes(
|
|
130
|
+
QueueUrl=queue_url,
|
|
131
|
+
AttributeNames=['All']
|
|
132
|
+
)['Attributes']
|
|
133
|
+
|
|
134
|
+
click.echo(f"\n{display_name}:")
|
|
135
|
+
click.echo(f" Messages Available: {attrs.get('ApproximateNumberOfMessages', 0)}")
|
|
136
|
+
click.echo(f" Messages In Flight: {attrs.get('ApproximateNumberOfMessagesNotVisible', 0)}")
|
|
137
|
+
click.echo(f" Messages Delayed: {attrs.get('ApproximateNumberOfMessagesDelayed', 0)}")
|
|
138
|
+
|
|
139
|
+
except aws.sqs.exceptions.QueueDoesNotExist:
|
|
140
|
+
click.echo(f"\n{display_name}: Not found")
|
|
141
|
+
|
|
142
|
+
except Exception as e:
|
|
143
|
+
click.echo(f"Error getting queue stats: {e}", err=True)
|
|
144
|
+
|
|
145
|
+
@admin_group.command(name="secrets")
|
|
146
|
+
@click.option("--stack", default="landline-api", help="CloudFormation stack name")
|
|
147
|
+
@click.option("--show", is_flag=True, help="Show secret values (CAREFUL!)")
|
|
148
|
+
@click.option("--profile", help="AWS profile")
|
|
149
|
+
@click.option("--region", help="AWS region")
|
|
150
|
+
def manage_secrets(stack, show, profile, region):
|
|
151
|
+
"""Manage secrets"""
|
|
152
|
+
aws = AWSClient(region=region, profile=profile)
|
|
153
|
+
secret_name = f"{stack}-secrets"
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
response = aws.secretsmanager.describe_secret(SecretId=secret_name)
|
|
157
|
+
click.echo(f"\nSecret: {secret_name}")
|
|
158
|
+
click.echo(f"ARN: {response['ARN']}")
|
|
159
|
+
click.echo(f"Last Updated: {response.get('LastChangedDate', 'N/A')}")
|
|
160
|
+
|
|
161
|
+
if show and click.confirm("Show secret values? This will display sensitive data!"):
|
|
162
|
+
secret_value = aws.secretsmanager.get_secret_value(SecretId=secret_name)
|
|
163
|
+
secrets = json.loads(secret_value['SecretString'])
|
|
164
|
+
|
|
165
|
+
click.echo("\nSecret Values:")
|
|
166
|
+
for key, value in secrets.items():
|
|
167
|
+
# Mask most of the value
|
|
168
|
+
masked = value[:4] + "*" * (len(value) - 8) + value[-4:] if len(value) > 8 else "*" * len(value)
|
|
169
|
+
click.echo(f" {key}: {masked}")
|
|
170
|
+
|
|
171
|
+
except aws.secretsmanager.exceptions.ResourceNotFoundException:
|
|
172
|
+
click.echo(f"Secret '{secret_name}' not found")
|
|
173
|
+
except Exception as e:
|
|
174
|
+
click.echo(f"Error managing secrets: {e}", err=True)
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cache management commands
|
|
3
|
+
"""
|
|
4
|
+
import click
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
from ai_lls_lib.cli.aws_client import AWSClient
|
|
8
|
+
|
|
9
|
+
@click.group(name="cache")
|
|
10
|
+
def cache_group():
|
|
11
|
+
"""Cache management commands"""
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
@cache_group.command(name="stats")
|
|
15
|
+
@click.option("--stack", default="landline-api", help="CloudFormation stack name")
|
|
16
|
+
@click.option("--profile", help="AWS profile")
|
|
17
|
+
@click.option("--region", help="AWS region")
|
|
18
|
+
def cache_stats(stack, profile, region):
|
|
19
|
+
"""Show cache statistics"""
|
|
20
|
+
aws = AWSClient(region=region, profile=profile)
|
|
21
|
+
cache_table = aws.get_table_name(stack, "PhoneCacheTable")
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
# Get table description for item count
|
|
25
|
+
table = aws.dynamodb.Table(cache_table)
|
|
26
|
+
desc = table.meta.client.describe_table(TableName=cache_table)
|
|
27
|
+
|
|
28
|
+
click.echo(f"\nCache Table: {cache_table}")
|
|
29
|
+
click.echo("=" * 50)
|
|
30
|
+
click.echo(f"Item Count: {desc['Table']['ItemCount']:,}")
|
|
31
|
+
click.echo(f"Table Size: {desc['Table']['TableSizeBytes']:,} bytes")
|
|
32
|
+
click.echo(f"Status: {desc['Table']['TableStatus']}")
|
|
33
|
+
|
|
34
|
+
# Sample some items to show age distribution
|
|
35
|
+
items = aws.scan_table(cache_table, limit=100)
|
|
36
|
+
if items:
|
|
37
|
+
now = datetime.utcnow()
|
|
38
|
+
ages = []
|
|
39
|
+
for item in items:
|
|
40
|
+
if 'verified_at' in item:
|
|
41
|
+
verified = datetime.fromisoformat(item['verified_at'].replace('Z', '+00:00'))
|
|
42
|
+
age = (now - verified.replace(tzinfo=None)).days
|
|
43
|
+
ages.append(age)
|
|
44
|
+
|
|
45
|
+
if ages:
|
|
46
|
+
click.echo(f"\nCache Age (sample of {len(ages)} items):")
|
|
47
|
+
click.echo(f" Newest: {min(ages)} days")
|
|
48
|
+
click.echo(f" Oldest: {max(ages)} days")
|
|
49
|
+
click.echo(f" Average: {sum(ages)/len(ages):.1f} days")
|
|
50
|
+
|
|
51
|
+
except Exception as e:
|
|
52
|
+
click.echo(f"Error getting cache stats: {e}", err=True)
|
|
53
|
+
|
|
54
|
+
@cache_group.command(name="get")
|
|
55
|
+
@click.argument("phone_number")
|
|
56
|
+
@click.option("--stack", default="landline-api", help="CloudFormation stack name")
|
|
57
|
+
@click.option("--profile", help="AWS profile")
|
|
58
|
+
@click.option("--region", help="AWS region")
|
|
59
|
+
def cache_get(phone_number, stack, profile, region):
|
|
60
|
+
"""Get cached entry for a phone number"""
|
|
61
|
+
from ai_lls_lib.core.verifier import PhoneVerifier
|
|
62
|
+
from ai_lls_lib.core.cache import DynamoDBCache
|
|
63
|
+
|
|
64
|
+
aws = AWSClient(region=region, profile=profile)
|
|
65
|
+
cache_table = aws.get_table_name(stack, "PhoneCacheTable")
|
|
66
|
+
|
|
67
|
+
# Normalize phone first
|
|
68
|
+
verifier = PhoneVerifier(cache=DynamoDBCache(table_name=cache_table))
|
|
69
|
+
try:
|
|
70
|
+
normalized = verifier.normalize_phone(phone_number)
|
|
71
|
+
except ValueError as e:
|
|
72
|
+
click.echo(f"Invalid phone: {e}", err=True)
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
# Get from cache
|
|
76
|
+
cache = DynamoDBCache(table_name=cache_table)
|
|
77
|
+
result = cache.get(normalized)
|
|
78
|
+
|
|
79
|
+
if result:
|
|
80
|
+
click.echo(f"\nCached entry for {normalized}:")
|
|
81
|
+
click.echo(json.dumps(result.dict() if hasattr(result, 'dict') else result, indent=2, default=str))
|
|
82
|
+
else:
|
|
83
|
+
click.echo(f"No cache entry found for {normalized}")
|
|
84
|
+
|
|
85
|
+
@cache_group.command(name="invalidate")
|
|
86
|
+
@click.argument("phone_number")
|
|
87
|
+
@click.option("--stack", default="landline-api", help="CloudFormation stack name")
|
|
88
|
+
@click.option("--profile", help="AWS profile")
|
|
89
|
+
@click.option("--region", help="AWS region")
|
|
90
|
+
@click.confirmation_option(prompt="Are you sure you want to invalidate this cache entry?")
|
|
91
|
+
def cache_invalidate(phone_number, stack, profile, region):
|
|
92
|
+
"""Invalidate cached entry for a phone number"""
|
|
93
|
+
from ai_lls_lib.core.verifier import PhoneVerifier
|
|
94
|
+
from ai_lls_lib.core.cache import DynamoDBCache
|
|
95
|
+
|
|
96
|
+
aws = AWSClient(region=region, profile=profile)
|
|
97
|
+
cache_table = aws.get_table_name(stack, "PhoneCacheTable")
|
|
98
|
+
|
|
99
|
+
# Normalize phone
|
|
100
|
+
verifier = PhoneVerifier(cache=DynamoDBCache(table_name=cache_table))
|
|
101
|
+
try:
|
|
102
|
+
normalized = verifier.normalize_phone(phone_number)
|
|
103
|
+
except ValueError as e:
|
|
104
|
+
click.echo(f"Invalid phone: {e}", err=True)
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
# Delete from cache
|
|
108
|
+
try:
|
|
109
|
+
aws.delete_item(cache_table, {"phone_number": normalized})
|
|
110
|
+
click.echo(f"Cache entry invalidated for {normalized}")
|
|
111
|
+
except Exception as e:
|
|
112
|
+
click.echo(f"Error invalidating cache: {e}", err=True)
|
|
113
|
+
|
|
114
|
+
@cache_group.command(name="clear")
|
|
115
|
+
@click.option("--stack", default="landline-api", help="CloudFormation stack name")
|
|
116
|
+
@click.option("--older-than", type=int, help="Clear entries older than N days")
|
|
117
|
+
@click.option("--profile", help="AWS profile")
|
|
118
|
+
@click.option("--region", help="AWS region")
|
|
119
|
+
@click.confirmation_option(prompt="This will clear cache entries. Continue?")
|
|
120
|
+
def cache_clear(stack, older_than, profile, region):
|
|
121
|
+
"""Clear cache entries"""
|
|
122
|
+
aws = AWSClient(region=region, profile=profile)
|
|
123
|
+
cache_table = aws.get_table_name(stack, "PhoneCacheTable")
|
|
124
|
+
|
|
125
|
+
if older_than:
|
|
126
|
+
click.echo(f"Clearing entries older than {older_than} days...")
|
|
127
|
+
cutoff = datetime.utcnow() - timedelta(days=older_than)
|
|
128
|
+
|
|
129
|
+
# Scan and delete old entries
|
|
130
|
+
items = aws.scan_table(cache_table, limit=1000)
|
|
131
|
+
deleted = 0
|
|
132
|
+
|
|
133
|
+
for item in items:
|
|
134
|
+
if 'verified_at' in item:
|
|
135
|
+
verified = datetime.fromisoformat(item['verified_at'].replace('Z', '+00:00'))
|
|
136
|
+
if verified.replace(tzinfo=None) < cutoff:
|
|
137
|
+
aws.delete_item(cache_table, {"phone_number": item['phone_number']})
|
|
138
|
+
deleted += 1
|
|
139
|
+
|
|
140
|
+
click.echo(f"Deleted {deleted} entries older than {older_than} days")
|
|
141
|
+
else:
|
|
142
|
+
click.echo("Full cache clear not implemented for safety. Use --older-than option.")
|