feat(web): Discord webhook + publish tale-web and tale-docs#1683
Conversation
…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.
📝 WalkthroughWalkthroughThis PR introduces standalone Docker Compose configurations and deployment infrastructure for Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
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.
There was a problem hiding this comment.
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.shand the new compose files are invisible to the CI path detector.
tests/_static-site-test.shdoesn't matchtests/container-*, so changes to the shared helper never setci_tests=true— and because the file also isn't in the top-levelon.push/pull_request pathsfilter (lines 7–29), the entire workflow won't even be triggered. The same gap applies tocompose.web.yml,compose.web.test.yml,compose.docs.yml, andcompose.docs.test.yml, which are also absent from the triggerpaths.🛠️ 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.pathsandon.push.pathsfilters (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
📒 Files selected for processing (16)
.github/workflows/build.yml.github/workflows/release.ymlcompose.docs.test.ymlcompose.docs.ymlcompose.web.test.ymlcompose.web.ymlcompose.ymlservices/docs/.env.exampleservices/web/.env.exampleservices/web/Dockerfile.dockerignoreservices/web/lib/forms/discord-embeds.tsservices/web/lib/forms/teams-cards.tsservices/web/server.tstests/_static-site-test.shtests/container-docs-test.shtests/container-web-test.sh
💤 Files with no reviewable changes (2)
- compose.yml
- services/web/lib/forms/teams-cards.ts
| 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" |
There was a problem hiding this comment.
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.
| const FIELD_VALUE_MAX = 1024; | ||
| const DESCRIPTION_MAX = 4096; | ||
| const USERNAME = 'Tale website'; |
There was a problem hiding this comment.
🧩 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:
- 1: https://docs.discord.com/developers/resources/webhook
- 2: https://discord.com/developers/docs/resources/channel?p=2670
- 3: https://docs.discord.com/developers/resources/message
- 4: https://discord.js.org/docs/packages/discord-api-types/main/v10/APIEmbedField:Interface
- 5: https://discord.js.org/docs/packages/discord-api-types/main/APIEmbed:Interface
- 6: https://discord.js.org/docs/packages/discord.js/main/APIEmbed:Interface
🏁 Script executed:
wc -l services/web/lib/forms/discord-embeds.tsRepository: tale-project/tale
Length of output: 106
🏁 Script executed:
cat -n services/web/lib/forms/discord-embeds.tsRepository: tale-project/tale
Length of output: 3876
🏁 Script executed:
find services/web/lib/forms -name "*.ts" -type f | grep -i schemaRepository: tale-project/tale
Length of output: 95
🏁 Script executed:
grep -r "RequestDemoPayload\|ContactPayload" services/web/lib/forms --include="*.ts" -lRepository: tale-project/tale
Length of output: 136
🏁 Script executed:
cat -n services/web/lib/forms/schemas.tsRepository: 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.
Summary
/api/forms/submit(Contact, Request Demo). The new builder posts a Discord embed (per-field length limits enforced) toWEB_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.webanddocsare independent of the platform stack (no shared state with convex/db/rag/crawler/proxy), so they move out ofcompose.yml/compose.test.ymland into compose.web.yml + compose.web.test.yml and compose.docs.yml + compose.docs.test.yml at the repo root. Each site gets its ownservices/<name>/.env.example.webanddocs, so tagged releases pushghcr.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 inbuild.ymlon PRs that change either site, and run from the container-test gate during releases.Pre-PR checklist
bun run --filter @tale/webtypecheck / lint / test (all pass).services/platform/messages/{en,de,fr}.json— N/A (no UI strings changed)./docs/{en,de,fr}/for every user-visible change — N/A (operator-only env var; documented inline inservices/web/.env.example).bun run --filter @tale/docs lintandbun run --filter @tale/docs test— N/A (no docs/ changes).README.md,README.de.md,README.fr.md— N/A.Test plan
WEB_DISCORD_WEBHOOK_URL(Server Settings → Integrations → Webhooks → New Webhook), submit each marketing form, confirm a Discord embed appears in the channel.WEB_DISCORD_WEBHOOK_URLunset, submit a form — confirm 503Service not configured.Web container testandDocs container testjobs pass on this PR.ghcr.io/tale-project/tale/tale-web:<v>andghcr.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 --buildbrings up the marketing site and/api/healthreturns 200 on :3001; same forcompose.docs.ymlon :3002.Summary by CodeRabbit
New Features
Chores