Skip to content

feat(web): Discord webhook + publish tale-web and tale-docs#1683

Merged
yannickmonney merged 4 commits into
mainfrom
feat/web-discord-webhook-publish-static-sites
May 8, 2026
Merged

feat(web): Discord webhook + publish tale-web and tale-docs#1683
yannickmonney merged 4 commits into
mainfrom
feat/web-discord-webhook-publish-static-sites

Conversation

@yannickmonney

@yannickmonney yannickmonney commented May 8, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Discord webhook for marketing site form submissions. Replaces the prior Microsoft Teams Adaptive-Card path on /api/forms/submit (Contact, Request Demo). The new builder posts a Discord embed (per-field length limits enforced) to WEB_DISCORD_WEBHOOK_URL. Documented in a new per-site services/web/.env.example so the marketing site is no longer coupled to the platform's root .env.example.
  • Standalone compose stacks for both static sites. web and docs are independent of the platform stack (no shared state with convex/db/rag/crawler/proxy), so they move out of compose.yml / compose.test.yml and into compose.web.yml + compose.web.test.yml and compose.docs.yml + compose.docs.test.yml at the repo root. Each site gets its own services/<name>/.env.example.
  • Publish under tale-web / tale-docs with full CI parity. Release workflow extends the build / manifest / release matrices and pull/verify loops to include web and docs, so tagged releases push ghcr.io/tale-project/tale/tale-{web,docs}. New per-site container tests (tests/container-web-test.sh, tests/container-docs-test.sh — thin wrappers over a shared tests/_static-site-test.sh helper) run as dedicated jobs in build.yml on PRs that change either site, and run from the container-test gate during releases.

Pre-PR checklist

  • Ran bun run --filter @tale/web typecheck / lint / test (all pass).
  • Updated services/platform/messages/{en,de,fr}.json — N/A (no UI strings changed).
  • Updated /docs/{en,de,fr}/ for every user-visible change — N/A (operator-only env var; documented inline in services/web/.env.example).
  • Ran bun run --filter @tale/docs lint and bun run --filter @tale/docs test — N/A (no docs/ changes).
  • Updated README.md, README.de.md, README.fr.md — N/A.

Test plan

  • In the deployment env, set WEB_DISCORD_WEBHOOK_URL (Server Settings → Integrations → Webhooks → New Webhook), submit each marketing form, confirm a Discord embed appears in the channel.
  • With WEB_DISCORD_WEBHOOK_URL unset, submit a form — confirm 503 Service not configured.
  • CI: confirm new Web container test and Docs container test jobs pass on this PR.
  • After a tag-driven release, confirm ghcr.io/tale-project/tale/tale-web:<v> and ghcr.io/tale-project/tale/tale-docs:<v> are pushed and pullable, and the container-test gate's web/docs steps green.
  • docker compose -f compose.web.yml up --build brings up the marketing site and /api/health returns 200 on :3001; same for compose.docs.yml on :3002.

Summary by CodeRabbit

  • New Features

    • Web and documentation services are now independently deployable via standalone Docker Compose configurations.
    • Contact form submissions now integrate with Discord webhooks.
  • Chores

    • Enhanced CI/CD pipeline with dedicated container testing and validation workflows for web and documentation services.

…issions

Form submissions on the marketing site (Contact, Request Demo) now post a
Discord embed via WEB_DISCORD_WEBHOOK_URL instead of an Adaptive Card to
WEB_TEAMS_WEBHOOK_URL. The embed enforces Discord's per-field length limits
and renders the message body as the embed description.

The new env var is documented in services/web/.env.example so the marketing
site stays decoupled from the platform stack's root .env.example.
…se stacks

The marketing site (services/web) and documentation site (services/docs)
are independent of the platform stack — neither shares state with convex,
db, rag, crawler, or proxy — so move them out of compose.yml /
compose.test.yml and into dedicated compose.web.yml + compose.web.test.yml
and compose.docs.yml + compose.docs.test.yml at the repo root. Each site
gets its own services/<name>/.env.example.

The release workflow now builds, gates, manifests, and verifies tale-web
and tale-docs alongside the six existing services so tagged releases push
ghcr.io/tale-project/tale/tale-{web,docs} images. New per-site container
tests (tests/container-web-test.sh, tests/container-docs-test.sh — both
thin wrappers over a shared static-site helper) run as standalone CI jobs
in build.yml on PRs that change either site, and run from the
container-test gate during releases.
@coderabbitai

coderabbitai Bot commented May 8, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This PR introduces standalone Docker Compose configurations and deployment infrastructure for web and docs services. It replaces the web server's Teams webhook integration with Discord webhook support, adding a new discord-embeds.ts module and removing the Teams Adaptive Cards module. A shared container test framework validates image properties (OCI labels, non-root user, healthcheck, size budget) and performs HTTP smoke tests. GitHub Actions workflows detect changes to these services via paths-filter, expand build and manifest matrices from 6 to 8 services, add dedicated container test jobs, and extend release pipelines to publish new service images.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The PR title accurately summarizes the two main changes: adding Discord webhook integration for the web service and publishing tale-web and tale-docs as standalone container images.
Description check ✅ Passed The PR description covers all required template sections with clear explanations of changes, rationale, and a comprehensive test plan. Pre-merge checklist items are properly marked.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/web-discord-webhook-publish-static-sites

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

The web Dockerfile copies workspace package.json files from
services/{platform,crawler,rag,db,proxy}, packages/tale_*, and
tools/{cli,plop} so `bun install` can resolve the workspace graph. Those
parent directories are excluded by services/web/Dockerfile.dockerignore
to keep the build context small, which causes the COPY of the manifests
to fail with "not found" once the cache is cold.

Add explicit `!path/package.json` re-includes after the broad excludes so
just the manifests are sent to the builder while source trees remain out
of the build context.
The runtime stage of services/web/Dockerfile only copies server.ts (and
not the lib/ tree it imports from, nor the workspace node_modules), so
`bun server.ts` fails at startup with "Cannot find module
./lib/forms/discord-embeds" — and would also fail on zod, which schemas.ts
imports from the workspace-hoisted node_modules.

Bundle server.ts via `bun build --target=bun` in the builder stage and
copy the resulting server.js into the runtime image. The bundle inlines
lib/forms/* and zod, leaving only Bun built-ins external. Drops the need
to ship lib/ or any node_modules at runtime.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
.github/workflows/build.yml (1)

85-91: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

tests/_static-site-test.sh and the new compose files are invisible to the CI path detector.

tests/_static-site-test.sh doesn't match tests/container-*, so changes to the shared helper never set ci_tests=true — and because the file also isn't in the top-level on.push/pull_request paths filter (lines 7–29), the entire workflow won't even be triggered. The same gap applies to compose.web.yml, compose.web.test.yml, compose.docs.yml, and compose.docs.test.yml, which are also absent from the trigger paths.

🛠️ Proposed fix
 ci_tests:
   - 'tests/container-*'
+  - 'tests/_static-site-test.sh'
   - 'compose.test.yml'
   - 'compose.yml'
+  - 'compose.web.yml'
+  - 'compose.web.test.yml'
+  - 'compose.docs.yml'
+  - 'compose.docs.test.yml'
   - '.env.test'
   - '.github/workflows/build.yml'
   - 'services/**'

Additionally add the same paths to the top-level on.pull_request.paths and on.push.paths filters (lines 7–29):

     paths:
       ...
       - 'compose.yml'
       - 'compose.test.yml'
+      - 'compose.web.yml'
+      - 'compose.web.test.yml'
+      - 'compose.docs.yml'
+      - 'compose.docs.test.yml'
       - 'tests/container-*'
+      - 'tests/_static-site-test.sh'
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/build.yml around lines 85 - 91, The CI path filters are
missing several files so changes to tests/_static-site-test.sh and the new
compose*.yml files don't trigger ci_tests or the workflow; update
.github/workflows/build.yml by adding tests/_static-site-test.sh and the compose
file globs (compose.web.yml, compose.web.test.yml, compose.docs.yml,
compose.docs.test.yml) to the ci_tests list (the block currently showing
'tests/container-*' etc.) and also add those same paths to the top-level
on.push.paths and on.pull_request.paths filters so edits to those files will set
ci_tests=true and trigger the workflow.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/build.yml:
- Around line 193-241: web-test and docs-test jobs only check
contains(fromJson(needs.changes.outputs.services), 'web'/'docs') so they don't
run when CI-only test scripts change (needs.changes.outputs.ci_tests == 'true');
update their if conditions to mirror smoke-test by OR-ing the ci_tests flag:
change the web-test and docs-test if: to
(contains(fromJson(needs.changes.outputs.services), 'web') ||
needs.changes.outputs.ci_tests == 'true') && github.event.pull_request.draft !=
true (and similarly for 'docs') so tests run when either the service is affected
or ci_tests is true.

In `@services/web/lib/forms/discord-embeds.ts`:
- Around line 28-30: The embed-building code currently truncates fields and
description independently, which can exceed Discord's 6000-char embed aggregate
limit; update the logic in the embed construction (where FIELD_VALUE_MAX,
DESCRIPTION_MAX and the description/field clipping occur—e.g., the functions
that build the embed object and clip field values/description) to enforce a
global budget: compute totalUsed = lengths of title/username/footer/static parts
+ sum(min(field.length, FIELD_VALUE_MAX) for each field), set remainingBudget =
6000 - totalUsed, then trim the description to min(DESCRIPTION_MAX,
remainingBudget) (and if remainingBudget < 0, further trim field values in a
deterministic order or cap them so total ≤ 6000); ensure the final embed
respects the 6000 char aggregate before sending the webhook.

---

Outside diff comments:
In @.github/workflows/build.yml:
- Around line 85-91: The CI path filters are missing several files so changes to
tests/_static-site-test.sh and the new compose*.yml files don't trigger ci_tests
or the workflow; update .github/workflows/build.yml by adding
tests/_static-site-test.sh and the compose file globs (compose.web.yml,
compose.web.test.yml, compose.docs.yml, compose.docs.test.yml) to the ci_tests
list (the block currently showing 'tests/container-*' etc.) and also add those
same paths to the top-level on.push.paths and on.pull_request.paths filters so
edits to those files will set ci_tests=true and trigger the workflow.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: b251cbd4-62cf-45b0-b337-f8a07110e933

📥 Commits

Reviewing files that changed from the base of the PR and between 647f5d5 and 072edde.

📒 Files selected for processing (16)
  • .github/workflows/build.yml
  • .github/workflows/release.yml
  • compose.docs.test.yml
  • compose.docs.yml
  • compose.web.test.yml
  • compose.web.yml
  • compose.yml
  • services/docs/.env.example
  • services/web/.env.example
  • services/web/Dockerfile.dockerignore
  • services/web/lib/forms/discord-embeds.ts
  • services/web/lib/forms/teams-cards.ts
  • services/web/server.ts
  • tests/_static-site-test.sh
  • tests/container-docs-test.sh
  • tests/container-web-test.sh
💤 Files with no reviewable changes (2)
  • compose.yml
  • services/web/lib/forms/teams-cards.ts

Comment on lines +193 to +241
web-test:
name: Web container test
needs: changes
if: contains(fromJson(needs.changes.outputs.services), 'web') && github.event.pull_request.draft != true
runs-on: ubuntu-latest
permissions:
contents: read
timeout-minutes: 15

steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6

- name: Reclaim disk space
run: |
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL
sudo docker image prune -af

- name: Run web container test
run: bash tests/container-web-test.sh

- name: Summary
if: always()
run: echo "## tale-web container test complete" >> "$GITHUB_STEP_SUMMARY"

docs-test:
name: Docs container test
needs: changes
if: contains(fromJson(needs.changes.outputs.services), 'docs') && github.event.pull_request.draft != true
runs-on: ubuntu-latest
permissions:
contents: read
timeout-minutes: 15

steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6

- name: Reclaim disk space
run: |
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL
sudo docker image prune -af

- name: Run docs container test
run: bash tests/container-docs-test.sh

- name: Summary
if: always()
run: echo "## tale-docs container test complete" >> "$GITHUB_STEP_SUMMARY"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

web-test and docs-test jobs never fire on test-infrastructure-only changes.

The smoke-test job's condition correctly handles CI-infra changes:

if: (needs.changes.outputs.services != '[]' || needs.changes.outputs.ci_tests == 'true') && ...

But web-test and docs-test only check the services list:

if: contains(fromJson(needs.changes.outputs.services), 'web') && ...

So a change to tests/container-web-test.sh (which sets ci_tests=true but adds nothing to services) will not trigger web-test, even though the test script itself changed. The fix mirrors the existing smoke-test pattern:

🛠️ Proposed fix
-    if: contains(fromJson(needs.changes.outputs.services), 'web') && github.event.pull_request.draft != true
+    if: (contains(fromJson(needs.changes.outputs.services), 'web') || needs.changes.outputs.ci_tests == 'true') && github.event.pull_request.draft != true
-    if: contains(fromJson(needs.changes.outputs.services), 'docs') && github.event.pull_request.draft != true
+    if: (contains(fromJson(needs.changes.outputs.services), 'docs') || needs.changes.outputs.ci_tests == 'true') && github.event.pull_request.draft != true
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/build.yml around lines 193 - 241, web-test and docs-test
jobs only check contains(fromJson(needs.changes.outputs.services), 'web'/'docs')
so they don't run when CI-only test scripts change
(needs.changes.outputs.ci_tests == 'true'); update their if conditions to mirror
smoke-test by OR-ing the ci_tests flag: change the web-test and docs-test if: to
(contains(fromJson(needs.changes.outputs.services), 'web') ||
needs.changes.outputs.ci_tests == 'true') && github.event.pull_request.draft !=
true (and similarly for 'docs') so tests run when either the service is affected
or ci_tests is true.

Comment on lines +28 to +30
const FIELD_VALUE_MAX = 1024;
const DESCRIPTION_MAX = 4096;
const USERNAME = 'Tale website';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

What are the current Discord webhook embed limits (total characters per embed, max fields, field name/value limits, and description limit) in the official Discord documentation?

💡 Result:

Current Discord webhook embed limits (from the official Discord developer docs / webhook + message resource specs): 1) Total characters per embed (embed structure total) - 6000 characters total per message for all embed objects combined in the request (the docs elsewhere describe “Up to 10 rich embeds (up to 6000 characters)” for webhook messages) [1][2]. - This means the 6000 limit applies across all embeds in the webhook message payload, not per individual embed object [2]. 2) Max fields - Up to 25 field objects per embed [3]. 3) Field name / field value limits - Field.name limit: 256 characters [4]. - Field.value limit: 1024 characters [4]. 4) Description limit - Embed.description limit: 4096 characters [5][6]. Other related “up to” limit explicitly tied to webhook messages - Webhook can include embeds array with up to 10 embed objects per message [1]. Sources (official Discord pages): - Webhook Resource (includes “embeds: array of up to 10 embed objects”) [1]. - Message / Embed object spec (includes “fields … max of 25”) [3]. - Embed field structure limits (field name 256, field value 1024) [4]. - Embed limits: embed description length 4096 (and embed length 6000 total) as reflected in the message embed type documentation pointers [5][6] and the webhook message cap that mentions “up to 6000 characters” [2].

Citations:


🏁 Script executed:

wc -l services/web/lib/forms/discord-embeds.ts

Repository: tale-project/tale

Length of output: 106


🏁 Script executed:

cat -n services/web/lib/forms/discord-embeds.ts

Repository: tale-project/tale

Length of output: 3876


🏁 Script executed:

find services/web/lib/forms -name "*.ts" -type f | grep -i schema

Repository: tale-project/tale

Length of output: 95


🏁 Script executed:

grep -r "RequestDemoPayload\|ContactPayload" services/web/lib/forms --include="*.ts" -l

Repository: tale-project/tale

Length of output: 136


🏁 Script executed:

cat -n services/web/lib/forms/schemas.ts

Repository: tale-project/tale

Length of output: 3857


Enforce Discord embed aggregate character limit to prevent webhook rejection

Current code clips individual fields and description independently, but Discord enforces a 6000 character total per embed. With all fields populated at their limits, valid form submissions can generate payloads exceeding this threshold, causing Discord webhooks to reject the request with a 400 error.

The suggested fix adds budget-aware description clipping that respects the aggregate limit, preventing form submission failures.

Suggested fix
 const FIELD_VALUE_MAX = 1024;
-const DESCRIPTION_MAX = 4096;
+const DESCRIPTION_MAX = 4096;
+const EMBED_TOTAL_MAX = 6000;
+
+function totalChars(title: string, fields: DiscordEmbedField[], description?: string): number {
+  return (
+    title.length +
+    (description?.length ?? 0) +
+    fields.reduce((n, f) => n + f.name.length + f.value.length, 0)
+  );
+}
+
+function fitDescriptionToEmbedBudget(
+  title: string,
+  fields: DiscordEmbedField[],
+  description: string | undefined,
+): string | undefined {
+  if (!description) return undefined;
+  const base = totalChars(title, fields, undefined);
+  const remaining = EMBED_TOTAL_MAX - base;
+  if (remaining <= 0) return undefined;
+  return clip(description, Math.min(DESCRIPTION_MAX, remaining));
+}
@@
   return {
     title: 'New demo request',
-    description: payload.message
-      ? clip(payload.message, DESCRIPTION_MAX)
-      : undefined,
+    description: fitDescriptionToEmbedBudget(
+      'New demo request',
+      fields,
+      payload.message,
+    ),
@@
   return {
     title: 'New contact message',
-    description: clip(payload.message, DESCRIPTION_MAX),
+    description: fitDescriptionToEmbedBudget(
+      'New contact message',
+      fields,
+      payload.message,
+    ),

Also applies to: 58–60, 83–85, 100–104

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/web/lib/forms/discord-embeds.ts` around lines 28 - 30, The
embed-building code currently truncates fields and description independently,
which can exceed Discord's 6000-char embed aggregate limit; update the logic in
the embed construction (where FIELD_VALUE_MAX, DESCRIPTION_MAX and the
description/field clipping occur—e.g., the functions that build the embed object
and clip field values/description) to enforce a global budget: compute totalUsed
= lengths of title/username/footer/static parts + sum(min(field.length,
FIELD_VALUE_MAX) for each field), set remainingBudget = 6000 - totalUsed, then
trim the description to min(DESCRIPTION_MAX, remainingBudget) (and if
remainingBudget < 0, further trim field values in a deterministic order or cap
them so total ≤ 6000); ensure the final embed respects the 6000 char aggregate
before sending the webhook.

@yannickmonney yannickmonney merged commit b2b88e9 into main May 8, 2026
13 checks passed
@yannickmonney yannickmonney deleted the feat/web-discord-webhook-publish-static-sites branch May 8, 2026 09:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant