mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-06-08 22:15:28 +00:00
289 lines
12 KiB
YAML
289 lines
12 KiB
YAML
AWSTemplateFormatVersion: '2010-09-09'
|
|
Transform: AWS::Serverless-2016-10-31
|
|
|
|
Description: >
|
|
miti99bot: Telegram bot on AWS Lambda (Go ZIP + LWA) with DynamoDB KV
|
|
+ EventBridge cron + SSM Parameter Store secrets. Strict free-tier deploy.
|
|
|
|
Parameters:
|
|
StackEnv:
|
|
Type: String
|
|
Default: prod
|
|
AllowedValues: [dev, prod]
|
|
Description: Environment suffix used in SSM parameter paths.
|
|
|
|
ModulesCSV:
|
|
Type: String
|
|
Default: util,misc,wordle,loldle,lolschedule,twentyq,trading,stats
|
|
Description: Comma-separated module names enabled at runtime (matches MODULES env).
|
|
|
|
BotOwnerID:
|
|
Type: String
|
|
Default: ""
|
|
Description: Telegram numeric user ID with bot-owner privileges. Empty disables Private/Protected commands.
|
|
|
|
AdminUserIDs:
|
|
Type: String
|
|
Default: ""
|
|
Description: Comma-separated Telegram user IDs allowed to use admin commands.
|
|
|
|
TradingIncomeEventsAPIURL:
|
|
Type: String
|
|
Default: "https://restv2.fireant.vn"
|
|
Description: FireAnt REST API base URL. Defaults to https://restv2.fireant.vn when omitted.
|
|
|
|
TradingIncomeEventsAPITokenParameterName:
|
|
Type: String
|
|
Default: ""
|
|
Description: Optional SSM SecureString parameter name containing bearer token for FireAnt REST API.
|
|
|
|
# AWS Lambda Web Adapter ARM64 layer ARN. Pin a specific version so deploys
|
|
# are reproducible. Bump by checking the latest at:
|
|
# https://github.com/awslabs/aws-lambda-web-adapter/releases
|
|
LambdaAdapterLayerArn:
|
|
Type: String
|
|
Default: arn:aws:lambda:ap-southeast-1:753240598075:layer:LambdaAdapterLayerArm64:25
|
|
Description: AWS Lambda Web Adapter layer ARN for the deploy region (ARM64).
|
|
|
|
AlertEmail:
|
|
Type: String
|
|
Default: ""
|
|
Description: Email for $1 budget alert. Leave empty to skip the budget resource.
|
|
|
|
# SSM holds the canonical value; CI fetches and passes via --parameter-overrides
|
|
# because EventBridge Connection's ApiKeyValue is consumed at stack-update time
|
|
# and stored in a service-linked secret (no per-invoke SSM fetch). NoEcho keeps
|
|
# the value out of CFN events / console / drift detection.
|
|
CronSharedSecret:
|
|
Type: String
|
|
NoEcho: true
|
|
Default: ""
|
|
Description: X-Cron-Token header value the EventBridge Rule presents to /cron/{name}. Must match the SSM-stored value the Lambda loads at cold start.
|
|
|
|
Conditions:
|
|
HasAlertEmail: !Not [!Equals [!Ref AlertEmail, ""]]
|
|
|
|
Globals:
|
|
Function:
|
|
Runtime: provided.al2023
|
|
Architectures: [arm64]
|
|
MemorySize: 256
|
|
Timeout: 30
|
|
Tracing: Active
|
|
LoggingConfig:
|
|
LogFormat: JSON
|
|
ApplicationLogLevel: INFO
|
|
SystemLogLevel: WARN
|
|
|
|
Resources:
|
|
|
|
# --- Storage --------------------------------------------------------------
|
|
|
|
BotTable:
|
|
Type: AWS::DynamoDB::Table
|
|
Properties:
|
|
TableName: !Sub "${AWS::StackName}-data"
|
|
BillingMode: PAY_PER_REQUEST
|
|
AttributeDefinitions:
|
|
- { AttributeName: pk, AttributeType: S }
|
|
- { AttributeName: sk, AttributeType: S }
|
|
KeySchema:
|
|
- { AttributeName: pk, KeyType: HASH }
|
|
- { AttributeName: sk, KeyType: RANGE }
|
|
PointInTimeRecoverySpecification:
|
|
PointInTimeRecoveryEnabled: false # paid feature; off for free tier
|
|
Tags:
|
|
- { Key: app, Value: miti99bot }
|
|
- { Key: env, Value: !Ref StackEnv }
|
|
|
|
# --- Compute --------------------------------------------------------------
|
|
|
|
BotFunctionLogGroup:
|
|
Type: AWS::Logs::LogGroup
|
|
Properties:
|
|
LogGroupName: !Sub "/aws/lambda/${AWS::StackName}"
|
|
RetentionInDays: 7
|
|
|
|
# Lambda emits a synthetic REPORT line at the end of every invocation.
|
|
# On a cold start that line includes "Init Duration: <ms>". This filter
|
|
# parses that field into a custom metric so the AWS-port plan's "P95 < 1.5s"
|
|
# cold-start abort criterion is observable from day one.
|
|
ColdStartMetricFilter:
|
|
Type: AWS::Logs::MetricFilter
|
|
Properties:
|
|
LogGroupName: !Ref BotFunctionLogGroup
|
|
FilterPattern: '[report="REPORT", reqid_label="RequestId:", reqid, dur_label="Duration:", dur, dur_unit="ms", bill_label="Billed", bill_dur_label, bill_dur, bill_unit, mem_label, mem_size_label, mem_size, mem_unit, max_label="Max", max_used_label="Memory", max_used_label2="Used:", max_used, max_used_unit, init_label="Init", init_dur_label="Duration:", init_dur, init_unit="ms"]'
|
|
MetricTransformations:
|
|
- MetricName: ColdStartInitDuration
|
|
MetricNamespace: miti99bot
|
|
MetricValue: $init_dur
|
|
Unit: Milliseconds
|
|
|
|
BotFunction:
|
|
Type: AWS::Serverless::Function
|
|
Properties:
|
|
FunctionName: !Ref AWS::StackName
|
|
CodeUri: build/lambda/
|
|
Handler: bootstrap
|
|
Layers:
|
|
- !Ref LambdaAdapterLayerArn
|
|
LoggingConfig:
|
|
LogGroup: !Ref BotFunctionLogGroup
|
|
Environment:
|
|
Variables:
|
|
PORT: "8080"
|
|
AWS_LAMBDA_EXEC_WRAPPER: /opt/bootstrap
|
|
READINESS_CHECK_PATH: /
|
|
# ---- App config (non-secret) ----
|
|
KV_PROVIDER: dynamodb
|
|
DYNAMODB_TABLE: !Ref BotTable
|
|
MODULES: !Ref ModulesCSV
|
|
BOT_OWNER_ID: !Ref BotOwnerID
|
|
ADMIN_USER_IDS: !Ref AdminUserIDs
|
|
TRADING_INCOME_EVENTS_API_URL: !Ref TradingIncomeEventsAPIURL
|
|
TRADING_INCOME_EVENTS_API_TOKEN_PARAMETER_NAME: !Ref TradingIncomeEventsAPITokenParameterName
|
|
# ---- Secrets (fetched from Parameter Store at Lambda cold start) ----
|
|
TELEGRAM_BOT_TOKEN_PARAMETER_NAME: !Sub "/miti99bot/${StackEnv}/telegram-bot-token"
|
|
TELEGRAM_WEBHOOK_SECRET_PARAMETER_NAME: !Sub "/miti99bot/${StackEnv}/telegram-webhook-secret"
|
|
GEMINI_API_KEY_PARAMETER_NAME: !Sub "/miti99bot/${StackEnv}/gemini-api-key"
|
|
CRON_SHARED_SECRET_PARAMETER_NAME: !Sub "/miti99bot/${StackEnv}/cron-shared-secret"
|
|
FunctionUrlConfig:
|
|
AuthType: NONE
|
|
InvokeMode: BUFFERED
|
|
Cors:
|
|
AllowOrigins: ["*"] # Telegram doesn't send CORS; safe default
|
|
AllowMethods: ["POST", "GET"]
|
|
AllowHeaders: ["*"]
|
|
Policies:
|
|
- DynamoDBCrudPolicy:
|
|
TableName: !Ref BotTable
|
|
- Statement:
|
|
- Effect: Allow
|
|
Action:
|
|
- ssm:GetParameter
|
|
- ssm:GetParameters
|
|
Resource: !Sub "arn:${AWS::Partition}:ssm:${AWS::Region}:${AWS::AccountId}:parameter/miti99bot/${StackEnv}/*"
|
|
|
|
# --- Cron -----------------------------------------------------------------
|
|
#
|
|
# Why EventBridge Scheduler + direct Lambda invoke with a synthetic
|
|
# Function-URL-v2 event in Input:
|
|
#
|
|
# 1. CloudFormation's AWS::Scheduler::Schedule has no schema slot for HTTPS
|
|
# universal-target invocation, so we can't have Scheduler POST to the
|
|
# Function URL the usual way.
|
|
# 2. The legacy AWS::Events::Rule + ApiDestination path works but bills
|
|
# $0.20 per million ApiDestination invocations (no free tier).
|
|
# 3. Scheduler → Lambda direct-invoke is fully free-tier (14M/mo). Lambda
|
|
# Web Adapter normally expects an HTTP-shaped event; we synthesise one
|
|
# in Target.Input so LWA proxies it to the local Go server as if it
|
|
# came from the Function URL. The /cron/{name} handler is unchanged.
|
|
#
|
|
# Trade-off accepted: the X-Cron-Token value is embedded plain in the
|
|
# schedule's Input and visible to anyone with scheduler:GetSchedule on
|
|
# this schedule. Mitigation: CronSharedSecret remains a NoEcho CFN
|
|
# parameter (out of template source + stack events), and rotation =
|
|
# SSM update + redeploy (same workflow as before).
|
|
|
|
CronDLQ:
|
|
Type: AWS::SQS::Queue
|
|
Properties:
|
|
QueueName: !Sub "${AWS::StackName}-cron-dlq"
|
|
MessageRetentionPeriod: 1209600 # 14 days
|
|
|
|
# Role Scheduler assumes to invoke the Lambda and write to the DLQ.
|
|
# lambda:InvokeFunction is the action for direct invoke (vs InvokeFunctionUrl
|
|
# which is HTTPS-only and Scheduler can't use via CFN anyway).
|
|
SchedulerExecutionRole:
|
|
Type: AWS::IAM::Role
|
|
Properties:
|
|
AssumeRolePolicyDocument:
|
|
Version: '2012-10-17'
|
|
Statement:
|
|
- Effect: Allow
|
|
Principal: { Service: scheduler.amazonaws.com }
|
|
Action: sts:AssumeRole
|
|
Policies:
|
|
- PolicyName: cron-invoke-lambda
|
|
PolicyDocument:
|
|
Version: '2012-10-17'
|
|
Statement:
|
|
- Effect: Allow
|
|
Action: lambda:InvokeFunction
|
|
Resource: !GetAtt BotFunction.Arn
|
|
- Effect: Allow
|
|
Action: sqs:SendMessage
|
|
Resource: !GetAtt CronDLQ.Arn
|
|
|
|
# cron(0 1 * * ? *) is 01:00 UTC = 08:00 ICT — matches dailyPushSchedule
|
|
# in internal/modules/lolschedule/cron.go.
|
|
#
|
|
# Input is a minimal Lambda Function URL v2 event payload. LWA detects this
|
|
# shape via requestContext.http and proxies it to the local :8080 server
|
|
# exactly as if the Function URL had been hit over HTTPS. The Go router
|
|
# reads X-Cron-Token from headers (case-insensitive) and routes by rawPath.
|
|
#
|
|
# $default and $context literals are not !Sub interpolation targets (no
|
|
# matching logical ID), so they pass through verbatim. Only ${CronSharedSecret}
|
|
# is interpolated.
|
|
LolscheduleDailyPushSchedule:
|
|
Type: AWS::Scheduler::Schedule
|
|
Properties:
|
|
Name: !Sub "${AWS::StackName}-lolschedule-daily-push"
|
|
Description: Fires lolschedule daily-push handler at 01:00 UTC (08:00 ICT)
|
|
ScheduleExpression: "cron(0 1 * * ? *)"
|
|
ScheduleExpressionTimezone: UTC
|
|
FlexibleTimeWindow: { Mode: "OFF" } # quoted: bare OFF → YAML 1.1 boolean false → EarlyValidation rejects
|
|
State: ENABLED
|
|
Target:
|
|
Arn: !GetAtt BotFunction.Arn
|
|
RoleArn: !GetAtt SchedulerExecutionRole.Arn
|
|
RetryPolicy:
|
|
MaximumRetryAttempts: 2
|
|
MaximumEventAgeInSeconds: 600
|
|
DeadLetterConfig:
|
|
Arn: !GetAtt CronDLQ.Arn
|
|
Input: !Sub |
|
|
{"version":"2.0","routeKey":"$default","rawPath":"/cron/lolschedule_daily_push","rawQueryString":"","headers":{"x-cron-token":"${CronSharedSecret}","content-type":"application/json","user-agent":"aws-scheduler"},"requestContext":{"http":{"method":"POST","path":"/cron/lolschedule_daily_push","protocol":"HTTP/1.1","sourceIp":"127.0.0.1","userAgent":"aws-scheduler"},"requestId":"scheduler-invoke","stage":"$default","time":"00:00:00","timeEpoch":0,"routeKey":"$default"},"body":"","isBase64Encoded":false}
|
|
|
|
# --- Cost guard -----------------------------------------------------------
|
|
|
|
MonthlyBudget:
|
|
Type: AWS::Budgets::Budget
|
|
Condition: HasAlertEmail
|
|
Properties:
|
|
Budget:
|
|
BudgetName: !Sub "${AWS::StackName}-monthly"
|
|
BudgetLimit: { Amount: '1', Unit: USD }
|
|
TimeUnit: MONTHLY
|
|
BudgetType: COST
|
|
NotificationsWithSubscribers:
|
|
- Notification:
|
|
ComparisonOperator: GREATER_THAN
|
|
NotificationType: ACTUAL
|
|
Threshold: 80
|
|
ThresholdType: PERCENTAGE
|
|
Subscribers:
|
|
- { Address: !Ref AlertEmail, SubscriptionType: EMAIL }
|
|
- Notification:
|
|
ComparisonOperator: GREATER_THAN
|
|
NotificationType: ACTUAL
|
|
Threshold: 100
|
|
ThresholdType: PERCENTAGE
|
|
Subscribers:
|
|
- { Address: !Ref AlertEmail, SubscriptionType: EMAIL }
|
|
|
|
Outputs:
|
|
FunctionUrl:
|
|
Description: Public Function URL — set this as the Telegram webhook
|
|
Value: !GetAtt BotFunctionUrl.FunctionUrl
|
|
TableName:
|
|
Description: DynamoDB table name (also exposed to the Lambda via DYNAMODB_TABLE)
|
|
Value: !Ref BotTable
|
|
LogGroup:
|
|
Description: CloudWatch log group for the bot Lambda
|
|
Value: !Ref BotFunctionLogGroup
|
|
CronDLQArn:
|
|
Description: ARN of the cron dead-letter queue
|
|
Value: !GetAtt CronDLQ.Arn
|