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,stock,coin,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. StockIncomeEventsAPIURL: Type: String Default: "https://restv2.fireant.vn" Description: FireAnt REST API base URL. Defaults to https://restv2.fireant.vn when omitted. StockIncomeEventsAPITokenParameterName: Type: String Default: "" Description: Optional SSM SecureString parameter name containing bearer token for FireAnt REST API. GoldPriceAPIURL: Type: String Default: "" Description: Optional gold spot price API URL override. Empty uses the built-in GoldPrice.org endpoint. GoldFXAPIURL: Type: String Default: "" Description: Optional USD/VND FX API URL override. Empty uses the built-in ExchangeRate-API open endpoint. GoldVNAppAPIURL: Type: String Default: "" Description: Optional VNAppMob API base URL override. Empty uses https://api.vnappmob.com. GoldVNAppAPIKeyParameterName: Type: String Default: "" Description: Optional SSM SecureString parameter name containing a pre-issued VNAppMob JWT API key. CoinBinanceAPIURL: Type: String Default: "" Description: Optional Binance ticker price API URL override. Empty uses the public market-data endpoint https://data-api.binance.vision/api/v3/ticker/price. CoinCoinbaseAPIURL: Type: String Default: "" Description: Optional Coinbase exchange-rates API URL override. Empty uses the built-in public endpoint. CoinCoinGeckoAPIURL: Type: String Default: "" Description: Optional CoinGecko simple price API URL override. Empty uses the built-in public endpoint. # 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: ". 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 STOCK_INCOME_EVENTS_API_URL: !Ref StockIncomeEventsAPIURL STOCK_INCOME_EVENTS_API_TOKEN_PARAMETER_NAME: !Ref StockIncomeEventsAPITokenParameterName GOLD_PRICE_API_URL: !Ref GoldPriceAPIURL GOLD_FX_API_URL: !Ref GoldFXAPIURL GOLD_VNAPP_API_URL: !Ref GoldVNAppAPIURL GOLD_VNAPP_API_KEY_PARAMETER_NAME: !Ref GoldVNAppAPIKeyParameterName COIN_BINANCE_API_URL: !Ref CoinBinanceAPIURL COIN_COINBASE_API_URL: !Ref CoinCoinbaseAPIURL COIN_COINGECKO_API_URL: !Ref CoinCoinGeckoAPIURL # ---- 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