Files
miti99bot/template.yaml
T
tiennm99 539e5bbefa feat(deploy): wire lolschedule cron via EventBridge Rule + ApiDestination
CloudFormation's AWS::Scheduler::Schedule Target schema has no property
for HTTPS universal invocation (URL, method, headers) — confirmed
against AWS docs. Switch to the legacy EventBridge Rule path which
supports HTTP targets natively via ApiDestination:

- AWS::Events::Connection: API_KEY auth, presents X-Cron-Token header.
  ApiKeyValue stored in EventBridge service-linked secret on stack
  update (no per-invoke SSM fetch, AWS-managed secret fees).
- AWS::Events::ApiDestination: POST to ${FunctionUrl}cron/lolschedule_daily_push.
- AWS::Events::Rule: cron(0 1 * * ? *) — daily 01:00 UTC / 08:00 ICT.
  Targets ApiDestination with retry x2, 600s max age, DLQ to CronDLQ.
- EventBridgeInvokeRole replaces SchedulerExecutionRole (events.amazonaws.com
  principal, events:InvokeApiDestination scoped to this destination only).

NoEcho CronSharedSecret CFN parameter restored; GHA fetches the SSM
SecureString and passes via --parameter-overrides so the value never
appears in template source or stack events.

Free-tier preserved: 1 invocation/day, well under EventBridge Rules +
ApiDestinations free quotas.
2026-05-18 15:14:06 +07:00

279 lines
10 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
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"
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 Rule + ApiDestination + Connection (not Scheduler):
# CloudFormation's AWS::Scheduler::Schedule Target schema has no property
# for HTTPS universal-target invocation (URL/method/headers). The legacy
# EventBridge Rule path does — via ApiDestination. Pure CFN, single-invoke
# per day, free tier covered.
CronDLQ:
Type: AWS::SQS::Queue
Properties:
QueueName: !Sub "${AWS::StackName}-cron-dlq"
MessageRetentionPeriod: 1209600 # 14 days
# Connection holds the auth credential. EventBridge persists ApiKeyValue in a
# service-linked secret in this account (fees absorbed by AWS per docs). The
# header name + value are presented on every ApiDestination invocation.
CronConnection:
Type: AWS::Events::Connection
Properties:
Name: !Sub "${AWS::StackName}-cron"
Description: Shared cron auth — presents X-Cron-Token on every /cron/* call
AuthorizationType: API_KEY
AuthParameters:
ApiKeyAuthParameters:
ApiKeyName: X-Cron-Token
ApiKeyValue: !Ref CronSharedSecret
# ApiDestination is the concrete endpoint EventBridge POSTs to. One per cron
# route — paths are not parameterised, so add another resource if/when a
# second cron handler ships. Keeping ${BotFunctionUrl.FunctionUrl} trailing
# slash + bare relative path gives a clean single-slash join.
LolscheduleDailyPushApiDestination:
Type: AWS::Events::ApiDestination
Properties:
Name: !Sub "${AWS::StackName}-lolschedule-daily-push"
ConnectionArn: !GetAtt CronConnection.Arn
HttpMethod: POST
InvocationEndpoint: !Sub "${BotFunctionUrl.FunctionUrl}cron/lolschedule_daily_push"
# Role EventBridge Rule assumes to invoke the ApiDestination and write to
# the DLQ. Locked to this destination's ArnForPolicy (the IAM-resource form,
# not the ARN form).
EventBridgeInvokeRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal: { Service: events.amazonaws.com }
Action: sts:AssumeRole
Policies:
- PolicyName: cron-invoke-apidestination
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action: events:InvokeApiDestination
Resource: !GetAtt LolscheduleDailyPushApiDestination.ArnForPolicy
- Effect: Allow
Action: sqs:SendMessage
Resource: !GetAtt CronDLQ.Arn
# The schedule itself. cron(0 1 * * ? *) is 01:00 UTC daily = 08:00 ICT,
# matching the dailyPushSchedule constant in internal/modules/lolschedule/cron.go.
LolscheduleDailyPushRule:
Type: AWS::Events::Rule
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 * * ? *)"
State: ENABLED
Targets:
- Id: lolschedule-daily-push
Arn: !GetAtt LolscheduleDailyPushApiDestination.Arn
RoleArn: !GetAtt EventBridgeInvokeRole.Arn
Input: "{}"
RetryPolicy:
MaximumRetryAttempts: 2
MaximumEventAgeInSeconds: 600
DeadLetterConfig:
Arn: !GetAtt CronDLQ.Arn
# --- 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