gotodeploy / cdk-valheim

A high level CDK construct of Valheim dedicated server.

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Add discord slash command support for ondemand fargate task control

gotodeploy opened this issue Β· comments

I was able to setup these three interactions using a simple Flask app with Lambda and API Gateway. API Gateway does not pass the Discord security headers through to the Lambda function, so I had to use request templates in order to do this, here's what my stack looks like using your ValheimWorld construct:

import os

from aws_cdk import core as cdk

# For consistency with other languages, `cdk` is the preferred import name for
# the CDK's core module.  The following line also imports it as `core` for use
# with examples from the CDK Developer's Guide, which are in the process of
# being updated to use `cdk`.  You may delete this import if you don't need it.

from aws_cdk import (
    core,
    aws_iam as iam,
    aws_lambda as _lambda,
    aws_apigateway as apigw,
    aws_applicationautoscaling as appScaling,
    aws_s3 as s3,
)
from cdk_valheim import ValheimWorld, ValheimWorldScalingSchedule


class CdkStack(cdk.Stack):

    def __init__(self, scope: cdk.Construct, construct_id: str, **kwargs) -> None:
        super().__init__(scope, construct_id, **kwargs)

        # The code that defines your stack goes here
        self.valheim_world = ValheimWorld(
            self,
            'ValheimWorld',
            cpu=2048,
            memory_limit_mib=4096,
            schedules=[ValheimWorldScalingSchedule(
                start=appScaling.CronOptions(hour='12', week_day='1-5'),
                stop=appScaling.CronOptions(hour='1', week_day='1-5'),
            )],
            environment={
                "SERVER_NAME": os.environ.get("SERVER_NAME", "CDK Valheim"),
                "WORLD_NAME": os.environ.get("WORLD_NAME", "Amazon"),
                "SERVER_PASS": os.environ.get("SERVER_PASS", "fargate"),
                "BACKUPS": 'false',
            })

        self.env_vars = {
            "APPLICATION_PUBLIC_KEY": os.environ.get("APPLICATION_PUBLIC_KEY"),
            "ECS_SERVICE_NAME": self.valheim_world.service.service_name,
            "ECS_CLUSTER_ARN": self.valheim_world.service.cluster.cluster_arn
        }

        self.flask_lambda_layer = _lambda.LayerVersion(
            self,
            "FlaskAppLambdaLayer",
            code=_lambda.AssetCode("./layers/flask"),
            compatible_runtimes=[_lambda.Runtime.PYTHON_3_8,],
        )

        self.flask_app_lambda = _lambda.Function(
            self,
            "FlaskAppLambda",
            runtime=_lambda.Runtime.PYTHON_3_8,
            code=_lambda.AssetCode('./lambda/functions/interactions'),
            function_name="flask-app-handler",
            handler="lambda-handler.handler",
            layers=[self.flask_lambda_layer],
            timeout=core.Duration.seconds(60),
            environment={**self.env_vars},
        )

        self.flask_app_lambda.role.add_managed_policy(
            iam.ManagedPolicy.from_managed_policy_arn(
                self,
                'ECS_FullAccessPolicy',
                managed_policy_arn='arn:aws:iam::aws:policy/AmazonECS_FullAccess'
            )
        )

        # https://slmkitani.medium.com/passing-custom-headers-through-amazon-api-gateway-to-an-aws-lambda-function-f3a1cfdc0e29
        self.request_templates = {
            "application/json": '''{
                "method": "$context.httpMethod", 
                "body" : $input.json("$"), 
                "headers": { 
                    #foreach($param in $input.params().header.keySet())        
                    "$param": "$util.escapeJavaScript($input.params().header.get($param))"
                    #if($foreach.hasNext),#end
                    #end
                }
            }
            '''
        }

        self.apigateway = apigw.RestApi(
            self, 
            'FlaskAppEndpoint',
        )

        self.apigateway.root.add_method("ANY")

        self.discord_interaction_webhook = self.apigateway.root.add_resource("discord")

        self.discord_interaction_webhook_integration = apigw.LambdaIntegration(
            self.flask_app_lambda,
            request_templates=self.request_templates
        )

        self.discord_interaction_webhook.add_method(
            'POST', 
            self.discord_interaction_webhook_integration
        )

Here's how I did the Lambda function to handle the Discord Interaction webhooks:

import os
import logging

import awsgi
import boto3
from discord_interactions import verify_key_decorator
from flask import (
    abort,
    Flask,
    jsonify,
    request
)


client = boto3.client('ecs')

# Your public key can be found on your application in the Developer Portal
PUBLIC_KEY = os.environ.get('APPLICATION_PUBLIC_KEY')

logger = logging.getLogger()
logger.setLevel(logging.INFO)

app = Flask(__name__)

# tell flask that we are serving that application at `/prod` (default for API Gateway)
# app.config["APPLICATION_ROOT"] = "/prod"


@app.route('/discord', methods=['POST'])
@verify_key_decorator(PUBLIC_KEY)
def index():
    if request.json["type"] == 1:
        return jsonify({"type": 1})
    else:
        logger.info(request.json)
        try:
            interaction_option = request.json["data"]["options"][0]["value"]
        except KeyError:
            logger.info("Could not parse the interaction option")
            interaction_option = "status"

        logger.info("Interaction:")
        logger.info(interaction_option)

        content = ""

        if interaction_option == "status":
            try:
                resp = client.describe_services(
                    cluster=os.environ.get("ECS_CLUSTER_ARN", ""),
                    services=[
                        os.environ.get("ECS_SERVICE_NAME", ""),
                    ]
                )
                desired_count = resp["services"][0]["desiredCount"]
                running_count = resp["services"][0]["runningCount"]
                pending_count = resp["services"][0]["pendingCount"]

                content = f"Desired: {desired_count} | Running: {running_count} | Pending: {pending_count}"

            except Error as e:
                content = "Could not get server status"
                logger.info("Could not get the server status")
                logger.info(e)

        elif interaction_option == "start":
            content = "Starting the server"

            resp = client.update_service(
                cluster=os.environ.get("ECS_CLUSTER_ARN", ""),
                service=os.environ.get("ECS_SERVICE_NAME", ""),
                desiredCount=1
            )

        elif interaction_option == "stop":
            content = "Stopping the server"

            resp = client.update_service(
                cluster=os.environ.get("ECS_CLUSTER_ARN", ""),
                service=os.environ.get("ECS_SERVICE_NAME", ""),
                desiredCount=0
            )

        else:
            content = "Unknown command"
        
        logger.info(resp)

        return jsonify({
            "type": 4,
            "data": {
                "tts": False,
                "content": content,
                "embeds": [],
                "allowed_mentions": { "parse": [] }
            }
        })

def handler(event, context):
    return awsgi.response(
        app,
        event,
        context,
        base64_content_types={"image/png"}
    )

Here's the script I'm using for creating the Interaction:

"""
https://discord.com/developers/docs/interactions/slash-commands#registering-a-command
"""

import os

import requests

APPLICATION_ID = os.environ.get("APPLICATION_ID")
GUILD_ID = os.environ.get("GUILD_ID")
BOT_TOKEN = os.environ.get("BOT_TOKEN")

url = f"https://discord.com/api/v8/applications/{APPLICATION_ID}/guilds/{GUILD_ID}/commands"

json = {
    "name": "vh",
    "description": "Start, stop or get the status of the Valheim server",
    "options": [
        {
            "name": "valheim_server_controls",
            "description": "What do you want to do?",
            "type": 3,
            "required": True,
            "choices": [
                {
                    "name": "status",
                    "value": "status"
                },
                {
                    "name": "start",
                    "value": "start"
                },
                {
                    "name": "stop",
                    "value": "stop"
                }
            ]
        },
    ]
}

# For authorization, you can use either your bot token 
headers = {
    "Authorization": f"Bot {BOT_TOKEN}"
}

# or a client credentials token for your app with the applications.commands.update scope
# headers = {
#     "Authorization": "Bearer abcdefg"
# }

if __name__ == "__main__":
    r = requests.post(url, headers=headers, json=json)
    # print(dir(r))
    print(r.content)

Does this make sense as a way to implement this feature, or did you have something else in mind? I would love to help contribute but I'm not super fluent in Typescript yet, but I thought I would share my approach to doing this in Python.

@briancaffey Any contribution is appreciated. I'd love to buy a beer for you 🍺 In the CDK part, I can re-implement it from Python to Typescript if you don't mind. Oh and I'm a Pythonista too. In the Flask part, it looks good to me since I supposed to try using FastAPI though.

@gotodeploy Thanks, I have learned a lot from your project, especially about EFS which I haven't used before. I don't think there is a need to re-implement anything in Python, Typescript seems to be the preferred way for writing constructs. For building stacks with reusable constructs I don't think it matters as much which language is used! I typically use Django for web projects, but the use case for the Discord webhook is so simple that Flask seemed appropriate. I still haven't worked with FastAPI.

I'm writing a step-by-step article describing the process for creating a Discord Interaction for scaling ECS Services for game servers, I'll share here when it is complete.

FYI I used both your (@briancaffey and @gotodeploy's) work as inspiration and did a similar integration supporting multiple servers in ts. Some things to note:

  • use SSM for passwords
  • add CW metrics for player count and online status (for later adding auto-shutoff)
  • more status output to discord for the /vh status <...> command, including a link to start the game directly towards the server.

If you have any questions, let me know!

@Grantapher Thanks for sharing πŸ‘ I haven't followed valheim-server-docker lately but using status.json for CW metrics is clever since I'm not sure this construct should depend on it though.

Yeah makes sense. You can also see how that project uses the steam query port and get the info directly from the source.

@Grantapher One-click joining link is fascinating... Where did you find the status code (0000-1110)? Is there any document?

P.S. Please let me know if you have an idea to make commands easily pluggable. Probably users don't want to have many lambdas and API GWs.

Info on the one click link can be found here. Ideally we should use the steam://connect/<IP or DNS name>[:<port>][/<password>] one but it doesn't work on Valheim's end, sadly. The password doesn't work either πŸ™.

The status code is just a mix of the info from the query port and the fargate (Running|Desired|Pending) counts. There is kinda a natural lifecycle (as in I just watched how the numbers change on startup πŸ™‚) of Desired going to 1, then Pending to 1, then pending to 0 running to 1, and then you wait for the status.json to show a non-error to get the online status. There are a bunch of "impossible" states not represented there as well.

For the CW metric recording lambda, it seems you can pass arbitrary json into the LambdaFunction event target via RuleTargetInput.fromObject(whateverObj), this could include which server's info to look up instead of using the env and one lambda per world.

For the discord interaction lambda, I believe it already is using only one lambda in the discord interaction stack. I suppose this could be better organized by not using a json blob in the lambda env, and instead use SSM or something similar.

I do appreciate the research πŸ™‡ I can see that there are many possibilities. My knowledge and time are limited, but I'm willing to implement some of these. PRs are always welcome though πŸ˜„

I've struggled with #75 these days. The PR is the core function but I'm not sure what is the best way to implement it. I think there should have two layers: the messaging layer and the command invoker layer. The messaging layer communicates with Discord or Slack and passes the command to the command invoker. The command invoker executes a proper function by the command. I suppose the function can be implemented by any language if it uses lambda extensions. I believe it makes anybody can implement their own commands.

Agreed that communication ought to be separate to handle separate requirements from different inbound platforms. I need to have command-only lambdas simply for setting up CW alarm actions to shutdown servers on empty.

I'll post here when I update my project some more. Once I have the stuff working on my end I'll look into integrating it into here but I too am time limited πŸ˜….

After looking at my composite alarm and when it would have shutoff servers, I decided it is working as intended and merged this PR to add the auto-shutoff on idle.

It is still a bit messy in that it creates a lambda per server and definitely isn't DRY, but for most folks I don't think this will be a major issue. I'll try to clean this up a bit better on my end after I get webhooks to announce state changes finished.

After looking at my composite alarm and when it would have shutoff servers, I decided it is working as intended and merged this PR to add the auto-shutoff on idle.

How do I actually trigger this?

I have a server that has been running all day while being empty.

Check out how I setup my stack with the ValheimWorld construct, lambda functions, and metrics.

Check out how I setup my stack with the ValheimWorld construct, lambda functions, and metrics.

Hey, thanks for coming back at me so fast with a reply, I was actually looking through your project, before I've read your reply, and had the incredible eureka moment to realize that the CDK maintained by you, is not part of the Valheim CDK.

I've been actually using Brians blog to set up the discord interactions because of the easy step by step guide and ease of copy pasting.
At this point I'm out of my element, on how I could integrate your work into what was already done, as it seems I might need to redeploy, I guess one of the solution would be to try to rewrite some of it in Python.

I know I am stretching the goodwill, but it might be helpful to set up a step by step starter guide for those like myself, that are only double digits in IQ.

You guys, for the better of humanity and those that can barely read code, such as myself, should standardize this and come up with a unified way of doing it. Thanks for taking the time to do it.

This issue is now marked as stale because it hasn't seen activity for a while. Add a comment or it will be closed soon. If you wish to exclude this issue from being marked as stale, add the "backlog" label.

Closing this issue as it hasn't seen activity for a while. Please add a comment @mentioning a maintainer to reopen. If you wish to exclude this issue from being marked as stale, add the "backlog" label.