diff --git a/.github/workflows/screenshot-failure-comment.yml b/.github/workflows/screenshot-failure-comment.yml index 35f5f3b..63b0c92 100644 --- a/.github/workflows/screenshot-failure-comment.yml +++ b/.github/workflows/screenshot-failure-comment.yml @@ -12,6 +12,7 @@ on: permissions: actions: read + contents: write pull-requests: write jobs: @@ -36,23 +37,71 @@ jobs: echo "found=false" >> "$GITHUB_OUTPUT" fi + - name: Checkout report branch base + if: steps.artifacts.outputs.found == 'true' + uses: actions/checkout@v4 + with: + ref: ${{ github.event.repository.default_branch }} + path: report-repo + fetch-depth: 1 + + - name: Publish screenshot report images + if: steps.artifacts.outputs.found == 'true' + id: publish + env: + GH_REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }} + RUN_ID: ${{ github.event.workflow_run.id }} + REPORT_BRANCH: screenshot-reports + shell: bash + run: | + set -euo pipefail + + cd report-repo + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + if git ls-remote --exit-code --heads origin "$REPORT_BRANCH" >/dev/null 2>&1; then + git fetch --depth=1 origin "$REPORT_BRANCH" + git switch -C "$REPORT_BRANCH" FETCH_HEAD + else + git switch --orphan "$REPORT_BRANCH" + git rm -rf . >/dev/null 2>&1 || true + fi + + report_dir="pr-$PR_NUMBER/run-$RUN_ID" + mkdir -p "$report_dir" + cp ../screenshot-artifacts/*.png "$report_dir"/ + + cat > "$report_dir/README.md" <> "$GITHUB_OUTPUT" + - name: Comment on pull request if: steps.artifacts.outputs.found == 'true' env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }} + RUN_ID: ${{ github.event.workflow_run.id }} + REPORT_BASE_URL: ${{ steps.publish.outputs.base_url }} shell: bash run: | set -euo pipefail - cat > screenshot-comment.md <<'EOF' + cat > screenshot-comment.md <> screenshot-comment.md <> screenshot-comment.md <> screenshot-comment.md <> screenshot-comment.md <> screenshot-comment.md <<'EOF' + cat >> screenshot-comment.md <` and media such as ``. All placeholder values are escaped before rendering so survey content cannot break the markup. +When `Message format` is `HTML`, the rendered template is sent through Telegram's `sendRichMessage` API using Rich HTML. This enables richer blocks such as headings, tables, `
` and media such as ``. All placeholder values are escaped before rendering so survey content cannot break the markup. ### Survey field placeholders diff --git a/img/settings.png b/img/settings.png index e6a82d3..97bf939 100644 Binary files a/img/settings.png and b/img/settings.png differ diff --git a/scripts/screenshots/README.md b/scripts/screenshots/README.md index 7b18000..2de5f47 100644 --- a/scripts/screenshots/README.md +++ b/scripts/screenshots/README.md @@ -46,5 +46,7 @@ Useful optional overrides: - `Documentation Screenshots` starts the repository-root disposable LimeSurvey stack with Docker Compose (`up -d --force-recreate`) and validates both `img/settings.png` and `img/telegram_example.png` in a single job. - The workflow should break when the plugin settings UI changes enough to make `img/settings.png` drift from the committed version. -When validation fails, the workflow summary tells you which command to run next and uploads diff artifacts under `test-results/screenshots/`. +When validation fails, the workflow summary tells you which command to run next and uploads the committed, generated, and visual-diff images under `test-results/screenshots/`. + +For pull requests, the `Screenshot failure comment` workflow downloads that artifact, publishes the images to the dedicated `screenshot-reports` branch, and comments on the PR with the committed screenshot, the generated screenshot, and the visual difference. The report branch is used because GitHub Actions tokens cannot upload PR comment attachments through `gh pr comment --attach`. diff --git a/scripts/screenshots/browser-flows.mjs b/scripts/screenshots/browser-flows.mjs index 6aab3d0..024404c 100644 --- a/scripts/screenshots/browser-flows.mjs +++ b/scripts/screenshots/browser-flows.mjs @@ -289,6 +289,37 @@ async function waitForDocumentFonts(page) { }); } +export async function fillTelegramConnectionSettings(page, maskedSettingsValues) { + await page.getByRole('textbox', { name: 'Bot token' }).fill(maskedSettingsValues.authToken); + await page.getByRole('textbox', { name: 'Chat ID' }).fill(maskedSettingsValues.chatId); +} + +export function getPluginSettingContainer(page, settingName) { + const settingField = page.locator( + `[name="${settingName}"], [name$="[${settingName}]"]` + ).first(); + + return settingField.locator( + 'xpath=ancestor::*[contains(concat(" ", normalize-space(@class), " "), " mb-3 ") or contains(concat(" ", normalize-space(@class), " "), " form-group ")][1]' + ); +} + +export async function enableCustomMessageSettings(page) { + const sendMessage = page.getByRole('checkbox', { name: 'Send a custom message' }); + const messageFormatContainer = getPluginSettingContainer(page, 'ParseMode'); + const messageTemplateContainer = getPluginSettingContainer(page, 'DefaultText'); + const messageTemplate = page.getByRole('textbox', { name: 'Message template' }); + + await messageFormatContainer.waitFor({ state: 'hidden' }); + await messageTemplateContainer.waitFor({ state: 'hidden' }); + + await sendMessage.check(); + await messageFormatContainer.waitFor({ state: 'visible' }); + await messageTemplateContainer.waitFor({ state: 'visible' }); + + return messageTemplate; +} + export async function captureSettingsScreenshot(page, { configureUrl, pluginName, @@ -299,10 +330,9 @@ export async function captureSettingsScreenshot(page, { await page.goto(configureUrl, { waitUntil: 'networkidle' }); await page.getByRole('tab', { name: 'Settings' }).click(); - await page.getByRole('textbox', { name: 'Auth Token' }).fill(maskedSettingsValues.authToken); - await page.getByRole('textbox', { name: 'Chat id' }).fill(maskedSettingsValues.chatId); + await fillTelegramConnectionSettings(page, maskedSettingsValues); - const defaultTextField = page.getByRole('textbox', { name: 'Default Text' }); + const defaultTextField = await enableCustomMessageSettings(page); await defaultTextField.fill(maskedSettingsValues.defaultText); await defaultTextField.evaluate((element) => { diff --git a/scripts/screenshots/checker.mjs b/scripts/screenshots/checker.mjs index 9475a94..768ab42 100644 --- a/scripts/screenshots/checker.mjs +++ b/scripts/screenshots/checker.mjs @@ -14,12 +14,26 @@ export function comparePngBuffers(expectedBuffer, actualBuffer, { const actualImage = PNG.sync.read(actualBuffer); if (expectedImage.width !== actualImage.width || expectedImage.height !== actualImage.height) { + const width = Math.max(expectedImage.width, actualImage.width); + const height = Math.max(expectedImage.height, actualImage.height); + const expectedCanvas = normalizePngCanvas(expectedImage, width, height); + const actualCanvas = normalizePngCanvas(actualImage, width, height); + const diffImage = new PNG({ width, height }); + const diffPixels = pixelmatch( + expectedCanvas.data, + actualCanvas.data, + diffImage.data, + width, + height, + { threshold: pixelmatchThreshold } + ); + return { matches: false, reason: `Image dimensions differ: expected ${expectedImage.width}x${expectedImage.height}, got ${actualImage.width}x${actualImage.height}`, - diffPixels: Number.POSITIVE_INFINITY, - diffPixelRatio: 1, - diffBuffer: null, + diffPixels, + diffPixelRatio: diffPixels / (width * height), + diffBuffer: PNG.sync.write(diffImage), }; } @@ -47,6 +61,21 @@ export function comparePngBuffers(expectedBuffer, actualBuffer, { }; } +function normalizePngCanvas(image, width, height) { + const canvas = new PNG({ width, height }); + + for (let index = 0; index < canvas.data.length; index += 4) { + canvas.data[index] = 255; + canvas.data[index + 1] = 255; + canvas.data[index + 2] = 255; + canvas.data[index + 3] = 255; + } + + PNG.bitblt(image, canvas, 0, 0, image.width, image.height, 0, 0); + + return canvas; +} + export async function assertScreenshotsUpToDate(config, { logger = console, browserType } = {}) { const screenshots = await captureScreenshots(config, { logger, browserType }); const refreshCommand = buildScreenshotCommand('screenshots:update', config.targets); diff --git a/src/Plugin/DefaultTextHelpBuilder.php b/src/Plugin/DefaultTextHelpBuilder.php index 4f8cdc2..9f25415 100644 --- a/src/Plugin/DefaultTextHelpBuilder.php +++ b/src/Plugin/DefaultTextHelpBuilder.php @@ -21,7 +21,7 @@ public function build(?int $surveyId = null): string '
    ' . $this->buildMetadataPlaceholderHelpItems() . '
', '

Survey field syntax

', '
    ' . $this->buildFieldSyntaxHelpItems() . '
', - '

When ParseMode is HTML, placeholder values are escaped automatically before rendering.

', + '

When Message format is HTML, placeholder values are escaped automatically before rendering.

', ]; if ($surveyId === null) { diff --git a/src/Plugin/LSTelegramNotifyPlugin.php b/src/Plugin/LSTelegramNotifyPlugin.php index 31772df..e9d8566 100644 --- a/src/Plugin/LSTelegramNotifyPlugin.php +++ b/src/Plugin/LSTelegramNotifyPlugin.php @@ -35,48 +35,39 @@ class LSTelegramNotifyPlugin extends \PluginBase protected $settings = [ 'Enable' => [ 'type' => 'checkbox', - 'label' => 'Enable telegram notifications', + 'label' => 'Enable Telegram notifications', 'default' => true, ], 'AuthToken' => [ 'type' => 'string', - 'label' => 'Auth Token', - 'help' => 'Bot API auth token, you can get one at BotFather.', + 'label' => 'Bot token', + 'help' => 'Telegram Bot API token. Create or manage one with BotFather.', ], 'ChatId' => [ 'type' => 'string', - 'label' => 'Chat id', - 'help' => 'The ID of group that will receive the notification messages. You can add the bot RawDataBot to your group, get the chat_id and after remove this bot from group.', + 'label' => 'Chat ID', + 'help' => 'The ID of the Telegram group that will receive notifications. You can temporarily add RawDataBot to the group to find its chat ID, then remove the bot.', ], 'TestMessage' => [ 'type' => 'info', 'content' => '', ], + 'SendMessage' => [ + 'type' => 'checkbox', + 'label' => 'Send a custom message', + 'help' => 'Send a Telegram text message when a survey response is completed. Enable this to choose the message format and edit the template below.', + 'default' => false, + ], 'ParseMode' => [ 'type' => 'select', - 'label' => 'Parse mode', + 'label' => 'Message format', 'options' => array('HTML' => 'HTML', 'Markdown' => 'Markdown', 'MarkdownV2' => 'MarkdownV2', 'Text' => 'Text'), - 'help' => 'As the Telegram bot API formatting options.', + 'help' => 'Formatting used for the custom Telegram message. See the Telegram formatting options.', 'default' => 'HTML', ], - 'SendPdf' => [ - 'type' => 'checkbox', - 'label' => 'Check to send the answer as PDF file', - 'default' => false, - ], - 'SendCsv' => [ - 'type' => 'checkbox', - 'label' => 'Check to send all answers as CSV file', - 'default' => false, - ], - 'SendMessage' => [ - 'type' => 'checkbox', - 'label' => 'Check to send a text message using the default text template', - 'default' => false, - ], 'DefaultText' => [ 'type' => 'text', - 'label' => 'Default Text', + 'label' => 'Message template', 'default' => "New Survey Completed!\n" . "Title: {title}\n" . @@ -84,6 +75,16 @@ class LSTelegramNotifyPlugin extends \PluginBase "ResponseId: {responseId}\n" . "PDF: here" ], + 'SendPdf' => [ + 'type' => 'checkbox', + 'label' => 'Send the response as a PDF file', + 'default' => false, + ], + 'SendCsv' => [ + 'type' => 'checkbox', + 'label' => 'Send all survey responses as a CSV file', + 'default' => false, + ], ]; /** @@ -94,6 +95,7 @@ public function init(): void $this->settings['DefaultText']['help'] = $this->createDefaultTextHelpBuilder()->build(); $this->settings['TestMessage'] = $this->createTestMessageUiBuilder()->buildSetting(); $this->registerTestMessageScript(); + $this->registerMessageSettingsVisibilityScript(); $this->subscribe('newSurveySettings'); $this->subscribe('afterSurveyComplete'); $this->subscribe('beforeSurveySettings'); @@ -360,6 +362,11 @@ protected function createTestMessageUiBuilder(): TestMessageUiBuilder return new TestMessageUiBuilder(); } + protected function createMessageSettingsUiBuilder(): MessageSettingsUiBuilder + { + return new MessageSettingsUiBuilder(); + } + protected function createTelegramApi(string $authToken): Api { return new Api($authToken); @@ -488,7 +495,7 @@ public function beforeSurveySettings(): void ], 'ChatId' => [ 'type' => 'string', - 'label' => 'Chat id', + 'label' => $this->settings['ChatId']['label'], 'help' => $this->settings['ChatId']['help'], 'current' => $this->get( 'ChatId', @@ -498,6 +505,22 @@ public function beforeSurveySettings(): void ), ], 'TestMessage' => $this->createTestMessageUiBuilder()->buildSetting($surveyId), + 'SendMessage' => [ + 'type' => $this->settings['SendMessage']['type'], + 'label' => $this->settings['SendMessage']['label'], + 'help' => $this->settings['SendMessage']['help'], + 'current' => $this->get( + 'SendMessage', + 'Survey', + $surveyId, + $this->get( + 'SendMessage', + null, + null, + $this->settings['SendMessage']['default'] + ) + ), + ], 'ParseMode' => [ 'type' => $this->settings['ParseMode']['type'], 'label' => $this->settings['ParseMode']['label'], @@ -516,6 +539,22 @@ public function beforeSurveySettings(): void ) ), ], + 'DefaultText' => [ + 'type' => 'text', + 'label' => $this->settings['DefaultText']['label'], + 'help' => $defaultTextHelp, + 'current' => $this->get( + 'DefaultText', + 'Survey', + $surveyId, + $this->get( + 'DefaultText', + null, + null, + $this->settings['DefaultText']['default'] + ) + ), + ], 'SendPdf' => [ 'type' => $this->settings['SendPdf']['type'], 'label' => $this->settings['SendPdf']['label'], @@ -546,37 +585,7 @@ public function beforeSurveySettings(): void ) ), ], - 'SendMessage' => [ - 'type' => $this->settings['SendMessage']['type'], - 'label' => $this->settings['SendMessage']['label'], - 'current' => $this->get( - 'SendMessage', - 'Survey', - $surveyId, - $this->get( - 'SendMessage', - null, - null, - $this->settings['SendMessage']['default'] - ) - ), - ], - 'DefaultText' => [ - 'type' => 'text', - 'label' => 'Default Text', - 'help' => $defaultTextHelp, - 'current' => $this->get( - 'DefaultText', - 'Survey', - $surveyId, - $this->get( - 'DefaultText', - null, - null, - $this->settings['DefaultText']['default'] - ) - ), - ] + ] ] ); @@ -694,6 +703,21 @@ public function newSurveySettings(): void } } + protected function registerMessageSettingsVisibilityScript(): void + { + $script = $this->createMessageSettingsUiBuilder()->buildScript(); + + if ($script === '') { + return; + } + + \Yii::app()->getClientScript()->registerScript( + 'ls-telegram-notify-message-settings-visibility', + $script, + \LSYii_ClientScript::POS_POSTSCRIPT + ); + } + protected function registerSurveyPluginSettingsSaveWorkaround(): void { $saveUrl = \App()->createAbsoluteUrl( diff --git a/src/Plugin/MessageSettingsUiBuilder.php b/src/Plugin/MessageSettingsUiBuilder.php new file mode 100644 index 0000000..c25c77e --- /dev/null +++ b/src/Plugin/MessageSettingsUiBuilder.php @@ -0,0 +1,91 @@ + 0; + }); + + if (hasAddedNodes) { + updateLSTelegramNotifyMessageSettingsVisibility(); + } + }); + + lsTelegramNotifyMessageSettingsObserver.observe(document.body, { + childList: true, + subtree: true + }); +} + +$(document) + .off( + 'change.lsTelegramNotifyMessageSettings', + 'input[type="checkbox"][name$="[SendMessage]"], input[type="checkbox"][name="SendMessage"]' + ) + .on( + 'change.lsTelegramNotifyMessageSettings', + 'input[type="checkbox"][name$="[SendMessage]"], input[type="checkbox"][name="SendMessage"]', + updateLSTelegramNotifyMessageSettingsVisibility + ) + .off('shown.bs.tab.lsTelegramNotifyMessageSettings') + .on( + 'shown.bs.tab.lsTelegramNotifyMessageSettings', + '[data-bs-toggle="tab"], [data-toggle="tab"]', + updateLSTelegramNotifyMessageSettingsVisibility + ) + .off('pjax:scriptcomplete.lsTelegramNotifyMessageSettings') + .on( + 'pjax:scriptcomplete.lsTelegramNotifyMessageSettings', + updateLSTelegramNotifyMessageSettingsVisibility + ); +JS; + } +} diff --git a/src/Plugin/TestMessageUiBuilder.php b/src/Plugin/TestMessageUiBuilder.php index 3ab6e6e..59b1cf0 100644 --- a/src/Plugin/TestMessageUiBuilder.php +++ b/src/Plugin/TestMessageUiBuilder.php @@ -13,6 +13,7 @@ public function buildSetting(?int $surveyId = null): array return [ 'type' => 'info', + 'label' => 'Connection test', 'content' => '