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
3 changes: 0 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,6 @@ jobs:
- name: Build, test, and verify code quality
run: mvn -B -ntp -P strict verify

- name: Validate registry checksums against built artifacts
run: python3 scripts/plugins_repo.py validate --check-local-artifacts

- name: Upload packaged artifacts
if: always()
uses: actions/upload-artifact@v4
Expand Down
31 changes: 19 additions & 12 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ Follow them for `extension-api/`, `runtime-api/`, and `golemcore/*` modules unle
- Each plugin is versioned independently with SemVer.
- The source of truth for the current plugin version is the child module `pom.xml`.
- `plugin.yaml` and `registry/` metadata must match the module version.
- Current `registry/<owner>/<plugin>/versions/<current>.yaml` must point at the local `dist/...jar` artifact and its `checksumSha256` must match the built jar.
- Released plugin versions are immutable. Never replace the jar, checksum, or metadata of an already released `registry/<owner>/<plugin>/versions/<released>.yaml`.
- Code changes to a released plugin must ship as a new SemVer version. Do not rewrite the checksum of an existing released version in a PR.
- The checksum recorded in `registry/<owner>/<plugin>/versions/<new-version>.yaml` is derived from the artifact built by the release workflow from the repository state on the default branch.
- Parent `pom.xml` is repository-level and is not used for plugin release numbering.
- `extension-api/` produces `me.golemcore.plugins:golemcore-plugin-extension-api`, the extension contract shared by all plugins in this repository.
- `runtime-api/` produces `me.golemcore.plugins:golemcore-plugin-runtime-api`, the isolated engine-provided runtime interface layer consumed by plugins.
Expand Down Expand Up @@ -82,8 +84,10 @@ The repository pipelines are:
- validates commit subjects in PRs and on `main`
- `CI`
- validates repository structure and builds `extension-api/`, `runtime-api/`, and all plugins
- `CI / release_main`
- after a merge to the default branch, determines which plugins require a release, bumps versions, and publishes release artifacts
- `Release Plugin`
- bumps, packages, tags, and publishes one plugin release
- manually backfills or forces one plugin release from the default branch

The `CI` pipeline validates:

Expand All @@ -92,25 +96,26 @@ The `CI` pipeline validates:
- `golemcore-plugin-runtime-api` runtime boundary availability
- mandatory formatter wiring for `extension-api/`, `runtime-api/`, and `golemcore/*`
- plugin manifest and module version alignment
- registry checksum consistency against locally built `dist/` artifacts
- Maven build/tests plus mandatory PMD and SpotBugs checks (`-P strict verify`)
- plugin package and shaded artifact generation

The normal PR/build pipeline does not compare locally built jars against checksums of already released versions in `registry/`. That checksum is release metadata, not a PR maintenance task.

CI builds `golemcore-plugin-extension-api` inside the same reactor, so the plugins repository no longer needs to check out or install `golemcore-bot` during verification.

## Releases

Plugin releases are produced with the manual GitHub Actions workflow `Release Plugin`.
Normal plugin releases are produced automatically after a merge to the default branch. The `CI / release_main` job:

The workflow:
1. determines which plugins need a release from the merged commit range
2. derives the SemVer bump from conventional commits since the last plugin tag
3. bumps the plugin version
4. packages the plugin from the repository state on the default branch
5. writes fresh `registry/` metadata, including checksum and published timestamp, from that built artifact
6. publishes the jar to GitHub Packages
7. commits release metadata, tags the release, and publishes the jar plus checksum file to GitHub Releases

1. validates the repository
2. bumps one plugin version
3. packages that plugin
4. refreshes `registry/` metadata with checksum and published timestamp
5. commits the release metadata
6. tags the release
7. publishes the jar and checksum file to GitHub Releases
The manual GitHub Actions workflow `Release Plugin` exists for backfills or exceptional releases from the default branch. It follows the same packaging and checksum rules as the automatic release flow.

For local marketplace development after rebuilding plugin jars without a version bump, refresh registry metadata with:

Expand All @@ -120,6 +125,8 @@ Then verify the result with:

- `python3 scripts/plugins_repo.py validate --check-local-artifacts`

This local registry sync is only for local development or unreleased artifacts. Do not use it to rewrite the checksum of an already released version in a PR.

For normal releases use `bump=auto`. It derives `major` / `minor` / `patch` from conventional commits since the last plugin tag.

Use `version_override` only for backfills or exceptional releases.
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,9 @@ private List<PluginSettingsField> buildFields() {
.build());
}

private Map<String, Object> buildValues(RuntimeConfig.TelegramConfig telegram, RuntimeConfig.VoiceConfig voice) {
private Map<String, Object> buildValues(
RuntimeConfig.TelegramConfig telegram,
RuntimeConfig.VoiceConfig voice) {
Map<String, Object> values = new LinkedHashMap<>();
values.put("enabled", Boolean.TRUE.equals(telegram.getEnabled()));
values.put("token", "");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,11 @@
import org.telegram.telegrambots.meta.api.methods.send.SendMessage;
import org.telegram.telegrambots.meta.api.methods.send.SendPhoto;
import org.telegram.telegrambots.meta.api.methods.send.SendVoice;
import org.telegram.telegrambots.meta.api.objects.Document;
import org.telegram.telegrambots.meta.exceptions.TelegramApiRequestException;
import org.telegram.telegrambots.meta.api.objects.InputFile;
import org.telegram.telegrambots.meta.api.objects.Update;
import org.telegram.telegrambots.meta.api.objects.photo.PhotoSize;
import org.telegram.telegrambots.meta.generics.TelegramClient;

import java.io.ByteArrayInputStream;
Expand All @@ -62,8 +64,10 @@
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
Expand Down Expand Up @@ -104,6 +108,14 @@
public class TelegramAdapter implements ChannelPort, LongPollingSingleThreadUpdateConsumer {

private static final String CHANNEL_TYPE = "telegram";
private static final String METADATA_ATTACHMENTS = "attachments";
private static final String ATTACHMENT_TYPE = "type";
private static final String ATTACHMENT_MIME_TYPE = "mimeType";
private static final String ATTACHMENT_DATA_BASE64 = "dataBase64";
private static final String ATTACHMENT_NAME = "name";
private static final String IMAGE_ATTACHMENT_TYPE = "image";
private static final String DEFAULT_PHOTO_FILE_NAME = "telegram-photo.jpg";
private static final String DEFAULT_PHOTO_MIME_TYPE = "image/jpeg";
private static final String SETTINGS_COMMAND = "settings";
private static final int CALLBACK_DATA_PARTS_COUNT = 3;
private static final int TELEGRAM_MAX_MESSAGE_LENGTH = 4096;
Expand Down Expand Up @@ -379,9 +391,11 @@ private void handleMessage(Update update) {
metadata.put(ContextAttributes.CONVERSATION_KEY, activeConversationKey);
messageBuilder.metadata(metadata);

String inboundText = extractInboundText(telegramMessage);

// Handle text messages
if (telegramMessage.hasText()) {
String text = telegramMessage.getText();
if (telegramMessage.hasText() && inboundText != null) {
String text = inboundText;

// Handle commands
if (text.startsWith("/")) {
Expand Down Expand Up @@ -429,8 +443,10 @@ private void handleMessage(Update update) {
return;
}
}
}

messageBuilder.content(text);
if (inboundText != null && !inboundText.isBlank()) {
messageBuilder.content(inboundText);
}

// Handle voice messages
Expand All @@ -442,6 +458,8 @@ private void handleMessage(Update update) {
}
}

attachImageInputs(telegramMessage, metadata);

Message message = messageBuilder.build();

// Notify handlers (local copy to avoid race on volatile field)
Expand Down Expand Up @@ -492,6 +510,121 @@ private static final class InviteAttemptState {
private Instant cooldownUntil;
}

private String extractInboundText(org.telegram.telegrambots.meta.api.objects.message.Message telegramMessage) {
if (telegramMessage.hasText()) {
return telegramMessage.getText();
}

String caption = telegramMessage.getCaption();
return caption != null && !caption.isBlank() ? caption : null;
}

private void attachImageInputs(
org.telegram.telegrambots.meta.api.objects.message.Message telegramMessage,
Map<String, Object> metadata) {
List<Map<String, Object>> attachments = new ArrayList<>();

if (telegramMessage.hasPhoto() && telegramMessage.getPhoto() != null && !telegramMessage.getPhoto().isEmpty()) {
appendPhotoAttachment(telegramMessage.getPhoto(), attachments);
}

if (telegramMessage.hasDocument() && telegramMessage.getDocument() != null) {
appendImageDocumentAttachment(telegramMessage.getDocument(), attachments);
}

if (!attachments.isEmpty()) {
metadata.put(METADATA_ATTACHMENTS, attachments);
}
}

private void appendPhotoAttachment(List<PhotoSize> photos, List<Map<String, Object>> attachments) {
PhotoSize photo = photos.get(photos.size() - 1);
if (photo == null || photo.getFileId() == null || photo.getFileId().isBlank()) {
log.warn("Telegram photo update is missing file id, skipping image attachment");
return;
}

try {
byte[] imageBytes = downloadTelegramFile(photo.getFileId());
attachments.add(buildImageAttachment(DEFAULT_PHOTO_MIME_TYPE, imageBytes, DEFAULT_PHOTO_FILE_NAME));
} catch (Exception e) {
log.warn("Failed to download Telegram photo attachment", e);
}
}

private void appendImageDocumentAttachment(Document document, List<Map<String, Object>> attachments) {
String mimeType = resolveImageDocumentMimeType(document);
if (mimeType == null) {
return;
}

String fileId = document.getFileId();
if (fileId == null || fileId.isBlank()) {
log.warn("Telegram image document update is missing file id, skipping image attachment");
return;
}

try {
byte[] imageBytes = downloadTelegramFile(fileId);
attachments.add(buildImageAttachment(mimeType, imageBytes, resolveDocumentFileName(document, mimeType)));
} catch (Exception e) {
log.warn("Failed to download Telegram image document attachment", e);
}
}

private String resolveImageDocumentMimeType(Document document) {
String mimeType = document.getMimeType();
if (mimeType != null && mimeType.startsWith("image/")) {
return mimeType;
}

String fileName = document.getFileName();
if (fileName == null || fileName.isBlank()) {
return null;
}

String normalized = fileName.toLowerCase(Locale.ROOT);
if (normalized.endsWith(".jpg") || normalized.endsWith(".jpeg")) {
return "image/jpeg";
}
if (normalized.endsWith(".png")) {
return "image/png";
}
if (normalized.endsWith(".webp")) {
return "image/webp";
}
if (normalized.endsWith(".gif")) {
return "image/gif";
}
if (normalized.endsWith(".bmp")) {
return "image/bmp";
}
return null;
}

private String resolveDocumentFileName(Document document, String mimeType) {
String fileName = document.getFileName();
if (fileName != null && !fileName.isBlank()) {
return fileName;
}

return switch (mimeType) {
case "image/png" -> "telegram-image.png";
case "image/webp" -> "telegram-image.webp";
case "image/gif" -> "telegram-image.gif";
case "image/bmp" -> "telegram-image.bmp";
default -> DEFAULT_PHOTO_FILE_NAME;
};
}

private Map<String, Object> buildImageAttachment(String mimeType, byte[] imageBytes, String fileName) {
return Map.of(
ATTACHMENT_TYPE, IMAGE_ATTACHMENT_TYPE,
ATTACHMENT_MIME_TYPE, mimeType,
ATTACHMENT_DATA_BASE64, Base64.getEncoder().encodeToString(imageBytes),
ATTACHMENT_NAME, fileName);
}

private void processVoiceMessage(
org.telegram.telegrambots.meta.api.objects.message.Message telegramMessage,
Message.MessageBuilder messageBuilder) {
Expand Down Expand Up @@ -525,6 +658,10 @@ private void processVoiceMessage(
}

private byte[] downloadVoice(String fileId) throws TelegramApiException, java.io.IOException {
return downloadTelegramFile(fileId);
}

byte[] downloadTelegramFile(String fileId) throws TelegramApiException, java.io.IOException {
GetFile getFile = new GetFile(fileId);
org.telegram.telegrambots.meta.api.objects.File file = executeWithRetry(() -> telegramClient.execute(getFile));

Expand Down
Loading
Loading