Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .gcloudignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# ignore everything
*

# except the following
!.gcloudignore
!.main.py
!.requirements.txt
!telegram_bot/bot.py
!telegram_bot/menu.py
!telegram_bot/init.py
1 change: 1 addition & 0 deletions Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ python-dotenv = "*"
python-telegram-bot = "*"
pytz = "*"
functions-framework = "*"
pulumi-random = "*"

[requires]
python_version = "3.13"
234 changes: 233 additions & 1 deletion Pipfile.lock

Large diffs are not rendered by default.

28 changes: 22 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
SatO Telegram Bot
=================
# SatO Telegram Bot

This bot has some useful functionality for Satakuntalainen Osakunta. It can be found on Telegram as `@osakuntabot`.

Expand All @@ -10,9 +9,27 @@ Available commands:
/ruokalista
/tjviisi

## Development

Development
-----------
⚠️⚠️ Read the entirety of this section before making changes to the repository ⚠️⚠️

### Development flow

1. Always create a new branch for whatever you are working on
2. When you're done, create a pull request
3. Check the preview for what changes will be done upon deployment
4. If everything looks good, merge the pull request and the changes will be automatically done

### Warnings

The code in the `master` branch is automatically deployed. Therefore you should be careful as to what you merge to this branch.

Changes to the `infra/` directory modify the resources that are deployed on the cloud. These can incur extra cost if you are not careful.
Changes to the application source code (`main.py`, `telegram_bot/`) can also incur costs if they run for long or consume a lot of resources (processor time, bandwidth)

Be mindful to the changes you make. You can ask for help and comments on your pull request before merging. After large changes, monitor the costs incurred manually on the cloud dashboard.

### Local development

Get `pipenv` from [here](https://pipenv.pypa.io/en/latest/)

Expand All @@ -30,5 +47,4 @@ You can test the commands on commandline by:

python main.py /command [args]

The bot is hosted in Google Cloud Functions and CircleCI is used to continuously deploy new versions of the bot to GCP.
NB! If you add dependencies to the project, remember to generate a new requirements.txt with `pipenv lock -r` and push it to the repo. Pipfile is not supported by Google Cloud Functions.
NB! If you add dependencies to the project, remember to generate a new requirements.txt with `pipenv requirements > requirements.txt` and push it to the repo. Pipfile is not supported by Google Cloud Functions.
5 changes: 3 additions & 2 deletions infra/Pulumi.prod.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
encryptionsalt: v1:MpvbSqwJ0R0=:v1:Gb5Aayq2PEDs+22I:lFM2LBSteNXhvO4PjNRCgZ5h783eDQ==
encryptionsalt: v1:dPeFWTLi9AI=:v1:oVtFIvCQGewIBERG:46y/iE1CtLrjeTkc2SilpwEjfJg0iw==
config:
gcp:project: osakunta-telegram-bot
gcp:region: europe-north1
gcp:disableGlobalProjectWarning: "true"
298 changes: 220 additions & 78 deletions infra/__main__.py
Original file line number Diff line number Diff line change
@@ -1,106 +1,248 @@
"""A Python Pulumi program"""

import pulumi
import pulumi_gcp as gcp
import tarfile
import hashlib
import pulumi_gcp as gcp
import pulumi_random as random
import utils

# create .tar.gz source
gcp_config = pulumi.Config("gcp")
LOCATION = gcp_config.require("region")
STACK_NAME = pulumi.get_stack()

# SOURCE_TAR_NAME = "source.tar.gz"
project_id = random.RandomPet("project-id",
length=2
)

# tarfile_hash = None
# with tarfile.open(SOURCE_TAR_NAME, "w|gz") as tar:
# tar.add("../telegram_bot", arcname="telegram_bot")
# tar.add("../main.py", arcname="main.py")
# tar.add("../requirements.txt", arcname="requirements.txt")
# last_modified = max([member.mtime for member in tar.getmembers()])
# tarfile_hash = hashlib.sha256(str(last_modified).encode()).hexdigest()
project = gcp.organizations.Project("project",
name=f"Telegram Bot {STACK_NAME}",
project_id=project_id.id,
folder_id="452932952214"
)

# Enable required services / APIs
secretmanager_service = gcp.projects.Service("secretmanager-service",
project=project_id.id,
service="secretmanager.googleapis.com",
disable_on_destroy=True
)

# setup infrastructure
cloudbuild_service = gcp.projects.Service("cloudbuild-service",
project=project_id.id,
service="cloudbuild.googleapis.com",
disable_on_destroy=True
)

PROJECT_ID = "osakunta-telegram-bot"
LOCATION = "europe-north1"
cloudrun_service = gcp.projects.Service("cloudrun-service",
project=project_id.id,
service="run.googleapis.com",
disable_on_destroy=True
)

cloudfunctions_service = gcp.projects.Service("cloudfunctions-service",
project=project_id.id,
service="cloudfunctions.googleapis.com",
disable_on_destroy=True
)

# Set up secret to hold the Telegram API token
telegram_bot_token = gcp.secretmanager.Secret("telegram-bot-token",
secret_id="telegram-bot-token",
cloudresourcemanager_service = gcp.projects.Service("cloudresourcemanager-service",
project=project_id.id,
service="cloudresourcemanager.googleapis.com",
disable_on_destroy=True
)


# --- Set up github repo connection ---
github_token_secret = gcp.secretmanager.Secret("github-token-secret",
project=project_id.id,
secret_id="github-token",
replication={
"user_managed": {
"replicas": [{ "location": LOCATION }]
}
}
},
opts=pulumi.ResourceOptions(
depends_on=[secretmanager_service]
)
)

# Set up a service account that has access to the secret, for the Function to use
service_account = gcp.serviceaccount.Account("service-account",
account_id="telegram-bot-service-account",
display_name="Telegram Bot Service Account")

secret_access = gcp.secretmanager.SecretIamMember("secret-access",
secret_id=telegram_bot_token.id,
github_connection_service_account_secret_access = gcp.secretmanager.SecretIamMember("github-connection-service-account-secret-access",
project=project_id.id,
secret_id=github_token_secret.id,
role="roles/secretmanager.secretAccessor",
member=service_account.email.apply(lambda email: f"serviceAccount:{email}")
member=pulumi.Output.concat(
"serviceAccount:service-",
project.number,
"@gcp-sa-cloudbuild.iam.gserviceaccount.com"
),
opts=pulumi.ResourceOptions(
depends_on=[cloudbuild_service]
)
)

github_connection = gcp.cloudbuildv2.Connection("github-connection",
project=project_id.id,
name="github-connection",
location=LOCATION,
github_config={
"app_installation_id": 30357801,
"authorizer_credential": {
"oauth_token_secret_version": github_token_secret.name.apply(lambda name: f"{name}/versions/latest")
}
},
opts=pulumi.ResourceOptions(
depends_on=[github_connection_service_account_secret_access]
)
)

# Set up the source code
source_bucket = gcp.storage.Bucket("source-bucket",
github_repository = gcp.cloudbuildv2.Repository("github-repository",
project=project_id.id,
name="telegram-bot",
location=LOCATION,
name=f"{PROJECT_ID}-source-bucket",
parent_connection=github_connection.name,
remote_uri="https://github.com/osakunta/telegram-bot.git",
)

source_asset = pulumi.AssetArchive({
"telegram_bot": pulumi.FileArchive("../telegram_bot"),
"main.py": pulumi.FileAsset("../main.py"),
"requirements.txt": pulumi.FileAsset("../requirements.txt")
})
source_object = gcp.storage.BucketObject("source-object",
bucket=source_bucket.name,
name="telegram-bot-source",
source=source_asset
# --- Set up CI/CD ---

cicd_service_account = utils.service_account_with_roles(
"cicd-service-account",
[
"roles/logging.logWriter",
"roles/cloudfunctions.developer",
"roles/run.admin",
"roles/iam.serviceAccountUser",
"roles/storage.objectViewer",
"roles/artifactregistry.writer"
],
project=project_id.id,
account_id="cicd-service-account",
display_name="CICD Service Account"
)

# Set up the Function, which handles the requests
function = gcp.cloudfunctionsv2.Function("function",
location=LOCATION,
name="telegram-bot-function",
description="Cloud Run Function for handling telegram bot requests",
build_config={
"runtime": "python313",
"entryPoint": "telegram_bot",
"source": {
"storage_source": {
"bucket": source_bucket.name,
"object": source_object.name,
"generation": source_object.generation
}
}
},
service_config={
"availableMemory": "128Mi",
"maxInstanceCount": 1, # No need for more than one instance
"minInstanceCount": 0, # Important to allow scale-to-zero, to save costs
"service_account_email": service_account.email,
"ingressSettings": "ALLOW_ALL",
"secret_environment_variables": [{
"key": "TOKEN",
"project_id": PROJECT_ID,
"secret": telegram_bot_token.secret_id,
"version": "latest"
}],
}
runtime_service_account = utils.service_account_with_roles(
"runtime-service-account",
[ "roles/iam.serviceAccountUser" ],
project=project_id.id,
account_id="runtime-service-account",
display_name="Function Runtime Service Account"
)

# Finally, set an IAM policy to allow unauthenticated people (anyone) to invoke the function
# this has to be cloudrun.ServiceIamMember instead of cloudfunctions.FunctionIamMember
# because the function is v2
function_public_iam = gcp.cloudrunv2.ServiceIamMember("function-public-iam",
location=LOCATION,
name=function.name,
role="roles/run.invoker",
member="allUsers"
# used by the cloud function to access telegram API
telegram_api_token = utils.secret_with_access(
"telegram-api-token",
members=[runtime_service_account.member, cicd_service_account.member],
project=project_id.id,
secret_id="telegram-api-token",
replication={
"user_managed": {
"replicas": [{ "location": LOCATION }]
}
},
opts=pulumi.ResourceOptions(
depends_on=[secretmanager_service]
)
)

# telegram sends this alongside the updates to the bot, so that malicious actors cannot use the endpoint
telegram_webhook_token = utils.secret_with_access(
"telegram-webhook-token",
members=[runtime_service_account.member, cicd_service_account.member],
project=project_id.id,
secret_id="telegram-webhook-token",
replication={
"user_managed": {
"replicas": [{ "location": LOCATION }]
}
},
opts=pulumi.ResourceOptions(
depends_on=[secretmanager_service]
)
)

deploy_trigger = gcp.cloudbuild.Trigger("deploy-trigger",
project=project_id.id,
name="deploy",
location=LOCATION,
service_account=cicd_service_account.id,
repository_event_config={
"repository": github_repository.id,
"push": {
"branch": f"^{STACK_NAME}$",
}
},
build={
"steps": [
{
"id": "Deploy function",
"name": "gcr.io/google.com/cloudsdktool/cloud-sdk:slim",
"entrypoint": "gcloud",
"args": [
"functions", "deploy", "telegram-bot",
"--region", LOCATION,
"--runtime", "python313",
"--entry-point", "telegram_bot",
"--trigger-http",
"--allow-unauthenticated",
"--timeout", "5s",
"--gen2",
"--max-instances", "1",
"--min-instances", "0",
"--memory", "128Mi",
"--set-env-vars", "API_TOKEN=$$API_TOKEN,WEBHOOK_TOKEN=$$WEBHOOK_TOKEN",
"--clear-secrets",
"--source", ".",
"--run-service-account", runtime_service_account.email,
"--build-service-account", cicd_service_account.id,
],
"secretEnv": [
"API_TOKEN",
"WEBHOOK_TOKEN"
]
},
{
"id": "Get function URL",
"name": "gcr.io/google.com/cloudsdktool/cloud-sdk:slim",
"entrypoint": "bash",
"args": [
"-c",
project_id.id.apply(
lambda id:
f"gcloud functions describe telegram-bot --region={LOCATION} --project={id} --format='value(url)' > /workspace/url.txt"
)
]
},
{
"id": "Set webhook URL",
"name": "gcr.io/gcp-runtimes/ubuntu_20_0_4",
"entrypoint": "bash",
"args": [
"-c",
"curl -X POST \
-H \"Content-Type: application/json\" \
-d \"{\\\"url\\\":\\\"$(cat /workspace/url.txt)\\\",\\\"secret_token\\\":\\\"$${WEBHOOK_TOKEN}\\\"}\" \
\"https://api.telegram.org/bot$${API_TOKEN}/setWebhook\""
],
"secretEnv": [
"API_TOKEN",
"WEBHOOK_TOKEN"
]
}
],
"options": {
"logging": "CLOUD_LOGGING_ONLY"
},
"availableSecrets": {
"secretManager": [
{
"env": "API_TOKEN",
"versionName": telegram_api_token.name.apply(lambda name: f"{name}/versions/latest"),
},
{
"env": "WEBHOOK_TOKEN",
"versionName": telegram_webhook_token.name.apply(lambda name: f"{name}/versions/latest"),
}
]
},
},
opts=pulumi.ResourceOptions(
depends_on=[ cloudrun_service, cloudfunctions_service, cloudresourcemanager_service ]
)
)
Loading