diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 86d8b11..00c5e21 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a5a2fc2..7337934 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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///versions/.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///versions/.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///versions/.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. @@ -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: @@ -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: @@ -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. diff --git a/golemcore/telegram/src/main/java/me/golemcore/plugins/golemcore/telegram/TelegramPluginSettingsContributor.java b/golemcore/telegram/src/main/java/me/golemcore/plugins/golemcore/telegram/TelegramPluginSettingsContributor.java index f61730d..62d06c6 100644 --- a/golemcore/telegram/src/main/java/me/golemcore/plugins/golemcore/telegram/TelegramPluginSettingsContributor.java +++ b/golemcore/telegram/src/main/java/me/golemcore/plugins/golemcore/telegram/TelegramPluginSettingsContributor.java @@ -145,7 +145,9 @@ private List buildFields() { .build()); } - private Map buildValues(RuntimeConfig.TelegramConfig telegram, RuntimeConfig.VoiceConfig voice) { + private Map buildValues( + RuntimeConfig.TelegramConfig telegram, + RuntimeConfig.VoiceConfig voice) { Map values = new LinkedHashMap<>(); values.put("enabled", Boolean.TRUE.equals(telegram.getEnabled())); values.put("token", ""); diff --git a/golemcore/telegram/src/main/java/me/golemcore/plugins/golemcore/telegram/adapter/inbound/telegram/TelegramAdapter.java b/golemcore/telegram/src/main/java/me/golemcore/plugins/golemcore/telegram/adapter/inbound/telegram/TelegramAdapter.java index 052543e..3ba8a8c 100644 --- a/golemcore/telegram/src/main/java/me/golemcore/plugins/golemcore/telegram/adapter/inbound/telegram/TelegramAdapter.java +++ b/golemcore/telegram/src/main/java/me/golemcore/plugins/golemcore/telegram/adapter/inbound/telegram/TelegramAdapter.java @@ -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; @@ -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; @@ -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; @@ -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("/")) { @@ -429,8 +443,10 @@ private void handleMessage(Update update) { return; } } + } - messageBuilder.content(text); + if (inboundText != null && !inboundText.isBlank()) { + messageBuilder.content(inboundText); } // Handle voice messages @@ -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) @@ -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 metadata) { + List> 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 photos, List> 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> 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 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) { @@ -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)); diff --git a/golemcore/telegram/src/test/java/me/golemcore/plugins/golemcore/telegram/adapter/inbound/telegram/TelegramAdapterHandleMessageTest.java b/golemcore/telegram/src/test/java/me/golemcore/plugins/golemcore/telegram/adapter/inbound/telegram/TelegramAdapterHandleMessageTest.java index 721a829..4c2de4c 100644 --- a/golemcore/telegram/src/test/java/me/golemcore/plugins/golemcore/telegram/adapter/inbound/telegram/TelegramAdapterHandleMessageTest.java +++ b/golemcore/telegram/src/test/java/me/golemcore/plugins/golemcore/telegram/adapter/inbound/telegram/TelegramAdapterHandleMessageTest.java @@ -14,20 +14,28 @@ import org.springframework.context.ApplicationEventPublisher; import org.telegram.telegrambots.longpolling.TelegramBotsLongPollingApplication; import org.telegram.telegrambots.meta.api.methods.send.SendMessage; +import org.telegram.telegrambots.meta.api.objects.Document; import org.telegram.telegrambots.meta.api.objects.Update; import org.telegram.telegrambots.meta.api.objects.User; +import org.telegram.telegrambots.meta.api.objects.photo.PhotoSize; import org.telegram.telegrambots.meta.generics.TelegramClient; +import java.util.Base64; import java.util.List; +import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.function.Consumer; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.mockito.Mockito.any; import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; import static org.mockito.Mockito.timeout; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -63,6 +71,7 @@ void setUp() { RuntimeConfigService runtimeConfigService = mock(RuntimeConfigService.class); when(runtimeConfigService.isTelegramEnabled()).thenReturn(true); when(runtimeConfigService.getTelegramToken()).thenReturn("test-token"); + when(runtimeConfigService.isTelegramTranscribeIncomingEnabled()).thenReturn(false); telegramSessionService = mock(TelegramSessionService.class); when(telegramSessionService.resolveActiveConversationKey(anyString())) .thenReturn("conv-active"); @@ -224,10 +233,7 @@ void shouldPassRegularTextToHandler() { Update update = createTextUpdate(123L, 100L, "Hello world"); adapter.consume(update); - ArgumentCaptor captor = ArgumentCaptor.forClass(Message.class); - verify(messageHandler).accept(captor.capture()); - - Message msg = captor.getValue(); + Message msg = captureInboundMessage(); assertEquals("Hello world", msg.getContent()); assertEquals("conv-active", msg.getChatId()); assertEquals("123", msg.getSenderId()); @@ -237,6 +243,72 @@ void shouldPassRegularTextToHandler() { assertEquals("conv-active", msg.getMetadata().get(ContextAttributes.CONVERSATION_KEY)); } + @Test + @SuppressWarnings("unchecked") + void shouldAttachPhotoAsImageAttachment() throws Exception { + adapter = spy(adapter); + adapter.onMessage(messageHandler); + doReturn(new byte[] { 1, 2, 3 }).when(adapter).downloadTelegramFile("photo-file-id"); + + Update update = createPhotoUpdate(123L, 100L, null, "photo-file-id"); + adapter.consume(update); + + Message msg = captureInboundMessage(); + Object attachmentsRaw = msg.getMetadata().get("attachments"); + assertNotNull(attachmentsRaw); + Map attachment = ((List>) attachmentsRaw).get(0); + assertEquals("image", attachment.get("type")); + assertEquals("image/jpeg", attachment.get("mimeType")); + assertEquals("telegram-photo.jpg", attachment.get("name")); + assertEquals(Base64.getEncoder().encodeToString(new byte[] { 1, 2, 3 }), attachment.get("dataBase64")); + } + + @Test + void shouldUseCaptionAsContentForPhotoMessage() throws Exception { + adapter = spy(adapter); + adapter.onMessage(messageHandler); + doReturn(new byte[] { 9, 8, 7 }).when(adapter).downloadTelegramFile("photo-file-id"); + + Update update = createPhotoUpdate(123L, 100L, "Describe this image", "photo-file-id"); + adapter.consume(update); + + Message msg = captureInboundMessage(); + assertEquals("Describe this image", msg.getContent()); + } + + @Test + @SuppressWarnings("unchecked") + void shouldAttachImageDocumentAsImageAttachment() throws Exception { + adapter = spy(adapter); + adapter.onMessage(messageHandler); + doReturn(new byte[] { 4, 5, 6 }).when(adapter).downloadTelegramFile("document-file-id"); + + Update update = createImageDocumentUpdate(123L, 100L, "diagram.png", "image/png", "document-file-id"); + adapter.consume(update); + + Message msg = captureInboundMessage(); + Object attachmentsRaw = msg.getMetadata().get("attachments"); + assertNotNull(attachmentsRaw); + Map attachment = ((List>) attachmentsRaw).get(0); + assertEquals("image", attachment.get("type")); + assertEquals("image/png", attachment.get("mimeType")); + assertEquals("diagram.png", attachment.get("name")); + assertEquals(Base64.getEncoder().encodeToString(new byte[] { 4, 5, 6 }), attachment.get("dataBase64")); + } + + @Test + void shouldNotOverrideModelTierForImageAttachment() throws Exception { + adapter = spy(adapter); + adapter.onMessage(messageHandler); + doReturn(new byte[] { 1, 1, 1 }).when(adapter).downloadTelegramFile("photo-file-id"); + + Update update = createPhotoUpdate(123L, 100L, null, "photo-file-id"); + adapter.consume(update); + + Message msg = captureInboundMessage(); + assertNull(msg.getMetadata().get("modelTier")); + } + // ===== Event publishing ===== @Test @@ -286,6 +358,12 @@ void shouldHandleMessageWithoutTextOrVoice() { // ===== Helpers ===== + private Message captureInboundMessage() { + ArgumentCaptor captor = ArgumentCaptor.forClass(Message.class); + verify(messageHandler).accept(captor.capture()); + return captor.getValue(); + } + private Update createTextUpdate(long userId, long chatId, String text) { User user = createUser(userId); org.telegram.telegrambots.meta.api.objects.message.Message telegramMsg = mock( @@ -305,6 +383,57 @@ private Update createTextUpdate(long userId, long chatId, String text) { return update; } + private Update createPhotoUpdate(long userId, long chatId, String caption, String fileId) { + User user = createUser(userId); + PhotoSize photo = mock(PhotoSize.class); + when(photo.getFileId()).thenReturn(fileId); + + org.telegram.telegrambots.meta.api.objects.message.Message telegramMsg = mock( + org.telegram.telegrambots.meta.api.objects.message.Message.class); + when(telegramMsg.getChatId()).thenReturn(chatId); + when(telegramMsg.getFrom()).thenReturn(user); + when(telegramMsg.getMessageId()).thenReturn(1); + when(telegramMsg.hasText()).thenReturn(false); + when(telegramMsg.getCaption()).thenReturn(caption); + when(telegramMsg.hasVoice()).thenReturn(false); + when(telegramMsg.hasPhoto()).thenReturn(true); + when(telegramMsg.getPhoto()).thenReturn(List.of(photo)); + when(telegramMsg.hasDocument()).thenReturn(false); + + Update update = mock(Update.class); + when(update.hasMessage()).thenReturn(true); + when(update.hasCallbackQuery()).thenReturn(false); + when(update.getMessage()).thenReturn(telegramMsg); + return update; + } + + private Update createImageDocumentUpdate(long userId, long chatId, String fileName, String mimeType, + String fileId) { + User user = createUser(userId); + Document document = mock(Document.class); + when(document.getFileId()).thenReturn(fileId); + when(document.getMimeType()).thenReturn(mimeType); + when(document.getFileName()).thenReturn(fileName); + + org.telegram.telegrambots.meta.api.objects.message.Message telegramMsg = mock( + org.telegram.telegrambots.meta.api.objects.message.Message.class); + when(telegramMsg.getChatId()).thenReturn(chatId); + when(telegramMsg.getFrom()).thenReturn(user); + when(telegramMsg.getMessageId()).thenReturn(1); + when(telegramMsg.hasText()).thenReturn(false); + when(telegramMsg.getCaption()).thenReturn(null); + when(telegramMsg.hasVoice()).thenReturn(false); + when(telegramMsg.hasPhoto()).thenReturn(false); + when(telegramMsg.hasDocument()).thenReturn(true); + when(telegramMsg.getDocument()).thenReturn(document); + + Update update = mock(Update.class); + when(update.hasMessage()).thenReturn(true); + when(update.hasCallbackQuery()).thenReturn(false); + when(update.getMessage()).thenReturn(telegramMsg); + return update; + } + private User createUser(long userId) { User user = mock(User.class); when(user.getId()).thenReturn(userId);