mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-09-16 06:20:00 +00:00
Implement a new stats module for the Telegram bot that tracks per-command usage with persistent KV storage. The module provides a /stats command displaying usage sorted by popularity with a 4096-byte Telegram message cap. Includes CommandHook integration for post-dispatch tracking via background goroutine (2s bounded context), proper test coverage, and registry initialization. Updated server config with stats factory and reserved concurrent execution control to prevent TOCTOU issues.
279 lines
11 KiB
YAML
279 lines
11 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.
|
|
|
|
# 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
|
|
# ---- 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"
|
|
# stats module uses non-atomic KV increment; concurrency > 1 would lose counts.
|
|
ReservedConcurrentExecutions: 1
|
|
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
|