Skip to content
Open
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
89 changes: 89 additions & 0 deletions .github/workflows/portainer-deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
name: Build and Deploy to Portainer

on:
push:
branches:
- main
- master
workflow_dispatch: {}

env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
TAG: latest

jobs:
build-and-deploy:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Set up JDK
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
cache: maven

- name: Build application
run: ./mvnw -B package -DskipTests

- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Build and push image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.TAG }}

- name: Deploy stack via Portainer API
env:
PORTAINER_URL: ${{ secrets.PORTAINER_URL }}
PORTAINER_USERNAME: ${{ secrets.PORTAINER_USERNAME }}
PORTAINER_PASSWORD: ${{ secrets.PORTAINER_PASSWORD }}
PORTAINER_STACK_ID: ${{ secrets.PORTAINER_STACK_ID }}
run: |
python - <<'PY'
import json
import os
import sys
from urllib import request

url = os.environ.get("PORTAINER_URL")
username = os.environ.get("PORTAINER_USERNAME")
password = os.environ.get("PORTAINER_PASSWORD")
stack_id = os.environ.get("PORTAINER_STACK_ID")

if not all([url, username, password, stack_id]):
sys.exit("Portainer secrets are not configured")

data = json.dumps({"Username": username, "Password": password}).encode()
req = request.Request(f"{url}/api/auth", data=data, headers={"Content-Type": "application/json"})
with request.urlopen(req) as resp:
token = json.loads(resp.read().decode()).get("jwt")

if not token:
sys.exit("Failed to obtain Portainer JWT token")

deploy_request = request.Request(
f"{url}/api/stacks/{stack_id}/deploy",
data=json.dumps({"prune": True, "pullImage": True}).encode(),
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
method="POST",
)

with request.urlopen(deploy_request) as resp:
print(f"Deploy response status: {resp.status}")
print(resp.read().decode())
PY
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,13 @@ For example you can write script to remove all messages with word `F*ck` in the
* Some another minor features like random meme generation and notifying all participants in the chat by @all.

I didn't try to write beautiful code so... it looks like a shit :)

## CI/CD

The repository now contains the workflow `.github/workflows/portainer-deploy.yml` that builds the
application, publishes an image to GitHub Container Registry and triggers a Portainer stack
redeploy. Configure the following secrets before enabling the workflow:

- `PORTAINER_URL` – base URL of the Portainer instance (for example `https://portainer.example.com`).
- `PORTAINER_USERNAME` / `PORTAINER_PASSWORD` – credentials with rights to deploy the target stack.
- `PORTAINER_STACK_ID` – identifier of the stack to redeploy.
7 changes: 4 additions & 3 deletions src/main/java/ru/holyway/botplatform/scripting/Script.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package ru.holyway.botplatform.scripting;

import lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.NotNull;

import java.util.Objects;
Expand All @@ -9,6 +10,7 @@
import java.util.function.Function;
import java.util.function.Predicate;

@Slf4j
public class Script implements Comparable<Script> {

private final String name;
Expand Down Expand Up @@ -40,10 +42,9 @@ public static Predicate<ScriptContext> any() {
public static Consumer<ScriptContext> sout(Object obj) {
return scriptContext -> {
if (obj instanceof Function) {
System.out
.println(((Function<ScriptContext, String>) obj).apply(scriptContext));
log.info(((Function<ScriptContext, String>) obj).apply(scriptContext));
} else {
System.out.println(String.valueOf(obj));
log.info(String.valueOf(obj));
}
};
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package ru.holyway.botplatform.scripting;

import javax.annotation.Nonnull;

/**
* Wraps compilation failures with additional context about the script text.
*/
public class ScriptCompilationException extends RuntimeException {

private final String scriptText;

public ScriptCompilationException(@Nonnull String message, @Nonnull String scriptText,
@Nonnull Throwable cause) {
super(message, cause);
this.scriptText = scriptText;
}

@Nonnull
public String getScriptText() {
return scriptText;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

import groovy.lang.GroovyShell;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

import javax.annotation.Nonnull;

@RequiredArgsConstructor
@Slf4j
public class ScriptCompilerImpl implements ScriptCompiler {

@Nonnull
Expand All @@ -14,6 +16,11 @@ public class ScriptCompilerImpl implements ScriptCompiler {
@Nonnull
@Override
public Script compile(@Nonnull String scriptText) {
return (Script) groovyShell.parse("return " + scriptText).run();
try {
return (Script) groovyShell.parse("return " + scriptText).run();
} catch (Exception ex) {
log.error("Failed to compile script: {}", scriptText, ex);
throw new ScriptCompilationException("Unable to compile script", scriptText, ex);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package ru.holyway.botplatform.scripting;

import org.telegram.telegrambots.meta.bots.AbsSender;
import ru.holyway.botplatform.scripting.entity.MessageScriptEntity;
import ru.holyway.botplatform.scripting.entity.StaticMessage;
import ru.holyway.botplatform.telegram.TelegramMessageEntity;

import javax.annotation.Nonnull;

/**
* Factory that encapsulates script context creation for different entry points
* (inline execution or scheduled trigger).
*/
public class ScriptContextFactory {

@Nonnull
public ScriptContext create(@Nonnull TelegramMessageEntity messageEntity, @Nonnull Script script) {
MessageScriptEntity message = new MessageScriptEntity(messageEntity);
TelegramScriptEntity telegram = new TelegramScriptEntity();
return new ScriptContext(message, telegram, script);
}

@Nonnull
public ScriptContext create(@Nonnull String chat, @Nonnull AbsSender sender, @Nonnull Script script) {
return create(new TelegramMessageEntity(new StaticMessage(chat), null, sender), script);
}
}
59 changes: 59 additions & 0 deletions src/main/java/ru/holyway/botplatform/scripting/ScriptRegistry.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package ru.holyway.botplatform.scripting;

import javax.annotation.Nonnull;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.BiConsumer;

/**
* Central repository for all runtime scripts. Manages sorting and lookup logic in a single place
* to avoid duplication inside processors.
*/
public class ScriptRegistry {

private final Map<String, List<Script>> scripts = new ConcurrentHashMap<>();

public void register(@Nonnull String chatId, @Nonnull Script script) {
scripts.computeIfAbsent(chatId, key -> new ArrayList<>());
scripts.get(chatId).add(script);
scripts.get(chatId).sort(Script::compareTo);
}

@Nonnull
public List<Script> getScripts(@Nonnull String chatId) {
return scripts.getOrDefault(chatId, new ArrayList<>());
}

public Optional<Script> findByContent(@Nonnull String chatId, @Nonnull String scriptText) {
return scripts.getOrDefault(chatId, Collections.emptyList()).stream()
.filter(script -> scriptText.equals(script.getStringScript()))
.findFirst();
}

public Optional<Script> findByHash(@Nonnull String chatId, int hash) {
return scripts.getOrDefault(chatId, Collections.emptyList()).stream()
.filter(script -> script.hashCode() == hash)
.findFirst();
}

public boolean removeByContent(@Nonnull String chatId, @Nonnull String scriptText) {
Optional<Script> script = findByContent(chatId, scriptText);
script.ifPresent(Script::cancel);
return script.isPresent() && scripts.getOrDefault(chatId, Collections.emptyList()).remove(script.get());
}

public boolean removeByHash(@Nonnull String chatId, int hash) {
Optional<Script> script = findByHash(chatId, hash);
script.ifPresent(Script::cancel);
return script.isPresent() && scripts.getOrDefault(chatId, Collections.emptyList()).remove(script.get());
}

public void clear(@Nonnull String chatId) {
scripts.getOrDefault(chatId, Collections.emptyList()).forEach(Script::cancel);
scripts.remove(chatId);
}

public void forEach(@Nonnull BiConsumer<String, List<Script>> consumer) {
scripts.forEach(consumer);
}
}
Loading
Loading