Implement Webhook, Cron, and Telegram Event Triggers - #5
Conversation
- Add webhook_trigger, cron_trigger, and telegram_event_trigger node types. - Support POST /webhooks/:workflowId for webhook execution. - Implement scheduled() handler in Cloudflare Worker for cron_trigger matching. - Support Telegram webhook trigger for event types (edited_message, new_chat_members, etc.). - Update scripts/executor.js and validation to support new trigger nodes. - Write extensive integration tests covering all trigger types. Co-authored-by: aethelred-agent-factory <238771426+aethelred-agent-factory@users.noreply.github.com>
|
👋 Jules, reporting for duty! I'm here to lend a hand with this pull request. When you start a review, I'll add a 👀 emoji to each comment to let you know I've read it. I'll focus on feedback directed at me and will do my best to stay out of conversations between you and other bots or reviewers to keep the noise down. I'll push a commit with your requested changes shortly after. Please note there might be a delay between these steps, but rest assured I'm on the job! For more direct control, you can switch me to Reactive Mode. When this mode is on, I will only act on comments where you specifically mention me with New to Jules? Learn more at jules.google/docs. For security, I will only act on instructions from the user who triggered this task. |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
📝 WalkthroughWalkthroughChangesWorkflow trigger support
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant TriggerSource
participant Worker
participant D1
participant GitHubActions
TriggerSource->>Worker: Submit webhook, Telegram update, or scheduled event
Worker->>Worker: Match configured trigger
Worker->>D1: Insert pending execution
Worker->>GitHubActions: Dispatch workflow with trigger_payload
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 10
🤖 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 `@scripts/executor.js`:
- Around line 126-127: Update the execution initialization in
scripts/executor.js to read the fired trigger’s ID/type from client_payload and
seed the queue only with that matching trigger node. Do not enqueue every
zero-incoming node; preserve marking the selected trigger as successful while
preventing unrelated trigger-root branches from running.
In `@scripts/test-api.js`:
- Around line 40-46: Update the test setup around the temporary worker/.dev.vars
write to snapshot whether the file exists and its original contents before
overwriting it. In the shared finally cleanup, restore the saved contents for
pre-existing files; only delete worker/.dev.vars when the test created it,
preserving current cleanup behavior otherwise.
- Around line 434-437: Enhance the Telegram and cron test flows around the
status checks to verify that each intended workflow was actually dispatched, not
merely that the entrypoint returned HTTP 200. Query or inspect execution
evidence associated with tgWfId and cronWfId, and fail each test when the
corresponding workflow execution record is absent.
In `@worker/src/index.js`:
- Around line 858-865: Align all trigger validation with runtime matcher
requirements: in worker/src/index.js lines 858-865 and scripts/validate.js lines
82-89, require syntactically valid cron expressions and non-empty supported
Telegram event strings, validating every array element; update
workflows/workflow.schema.json lines 132-164 with matching cron constraints and
Telegram enum, minLength, minItems, and uniqueItems restrictions.
- Around line 11-15: Secure the POST /webhooks/:id handler around the workflow
dispatch logic by authenticating each request with the workflow’s configured
signed secret, applying rate limiting, and rejecting requests whose supplied
chat_id is not in ALLOWED_CHAT_IDS. Ensure unauthorized or invalid requests are
denied before dispatch and cannot override the allowlisted notification
destination.
- Around line 572-599: Before the Telegram event-trigger workflow loop around
matchTelegramEvent, resolve the originating chat from the update and enforce
ALLOWED_CHAT_IDS; skip dispatch when the source is missing or unauthorized.
Reuse the resolved authorized chat for downstream processing, and remove any
fallback that substitutes the first allowed chat, including the related logic
around lines 634-658.
- Around line 69-100: The execution records created before dispatchWorkflow must
be marked failed when dispatch throws. In each dispatch flow, including the
shown path and the locations around the referenced additional paths, wrap
dispatchWorkflow in a catch that updates the matching executionId with status
failed, finished_at, and the failure log before returning or continuing;
preserve the existing success behavior.
- Around line 579-663: Move the error boundary from the outer workflow scan into
each `for (const wf of workflows)` iteration, wrapping that workflow’s trigger
matching, execution creation, chat ID resolution, and `dispatchWorkflow` calls
in its own try/catch. Log the workflow-specific failure and continue to the next
workflow so malformed configuration or dispatch errors do not abort remaining
workflows; apply the same per-workflow handling to the additional scan around
the corresponding workflow loop.
- Around line 81-100: Update the repository dispatch handling around
triggerPayload and dispatchWorkflow to validate the serialized client_payload
size before creating the pending execution row. Reject or otherwise handle
oversized payloads before dispatching, or replace the inline payload with an
external reference, while preserving normal dispatch behavior for payloads
within GitHub’s limit.
- Around line 1195-1252: Update matchCron’s matchField helper to strictly
validate cron tokens before matching: require complete numeric tokens for single
values, ranges, comma-separated parts, and step components; reject malformed or
out-of-range values; and require step to be greater than zero. Preserve existing
wildcard, day-of-week normalization, and valid step/range matching behavior.
🪄 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: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 8e116172-b484-4255-aa68-c1e5e1ea39d2
📒 Files selected for processing (5)
scripts/executor.jsscripts/test-api.jsscripts/validate.jsworker/src/index.jsworkflows/workflow.schema.json
| } else if (node.type === 'webhook_trigger' || node.type === 'cron_trigger' || node.type === 'telegram_event_trigger') { | ||
| entry.status = 'success'; |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
ast-grep outline scripts/executor.js --items all --type function --match 'runNode|execute|workflow'
rg -n -C5 '\b(runNode|inDegree|roots|queue|trigger_payload)\b' scripts/executor.js worker/src/index.jsRepository: quickerup/telegram-workflow-bot
Length of output: 4507
🏁 Script executed:
#!/bin/bash
sed -n '1,260p' scripts/executor.js
printf '\n--- worker ---\n'
sed -n '1080,1140p' worker/src/index.js
printf '\n--- trigger/type search ---\n'
rg -n 'webhook_trigger|cron_trigger|telegram_event_trigger|trigger_payload|client_payload|execution_id|trigger node|startNodes|incomingCount' scripts/executor.js worker/src/index.jsRepository: quickerup/telegram-workflow-bot
Length of output: 14054
Only seed execution from the fired trigger. scripts/executor.js starts every zero-incoming node, and trigger nodes are always marked successful. With multiple trigger roots, one event can run unrelated branches. Pass the fired trigger ID/type in client_payload and initialize the queue from that node only.
🤖 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 `@scripts/executor.js` around lines 126 - 127, Update the execution
initialization in scripts/executor.js to read the fired trigger’s ID/type from
client_payload and seed the queue only with that matching trigger node. Do not
enqueue every zero-incoming node; preserve marking the selected trigger as
successful while preventing unrelated trigger-root branches from running.
| console.log('Writing temporary worker/.dev.vars...'); | ||
| fs.writeFileSync('worker/.dev.vars', ` | ||
| TELEGRAM_BOT_TOKEN=123456:fake-token | ||
| TELEGRAM_WEBHOOK_SECRET=secret | ||
| GITHUB_PAT=github_pat_fake | ||
| ALLOWED_CHAT_IDS=12345 | ||
| `.trim()); |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Preserve an existing worker/.dev.vars file.
The test overwrites local developer secrets and then deletes the file. Snapshot any existing contents before writing and restore them in a shared finally cleanup; only delete the file when this test created it.
Also applies to: 499-506
🧰 Tools
🪛 ast-grep (0.44.1)
[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec.
Context: require('child_process')
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').
(detect-child-process)
🤖 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 `@scripts/test-api.js` around lines 40 - 46, Update the test setup around the
temporary worker/.dev.vars write to snapshot whether the file exists and its
original contents before overwriting it. In the shared finally cleanup, restore
the saved contents for pre-existing files; only delete worker/.dev.vars when the
test created it, preserving current cleanup behavior otherwise.
| if (resTriggerTg.statusCode !== 200) { | ||
| throw new Error(`Expected 200 from Telegram webhook, got ${resTriggerTg.statusCode}`); | ||
| } | ||
| console.log('Test 10 passed (Telegram webhook responded 200 OK)!'); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Assert that the intended workflow was actually dispatched.
These status checks only prove that the entrypoints responded. Telegram event matching or matchCron could be broken while both tests remain green. Verify an execution record or equivalent dispatch evidence tied to tgWfId and cronWfId.
Also applies to: 494-497
🧰 Tools
🪛 ast-grep (0.44.1)
[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec.
Context: require('child_process')
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').
(detect-child-process)
🤖 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 `@scripts/test-api.js` around lines 434 - 437, Enhance the Telegram and cron
test flows around the status checks to verify that each intended workflow was
actually dispatched, not merely that the entrypoint returned HTTP 200. Query or
inspect execution evidence associated with tgWfId and cronWfId, and fail each
test when the corresponding workflow execution record is absent.
| // POST /webhooks/:id | ||
| if (request.method === 'POST' && url.pathname.startsWith('/webhooks/')) { | ||
| const parts = url.pathname.split('/'); | ||
| if (parts.length === 3) { | ||
| const id = parts[2]; |
There was a problem hiding this comment.
🔒 Security & Privacy | 🔴 Critical | 🏗️ Heavy lift
Authenticate webhook invocations and reject caller-selected unauthorized chats.
The route bypasses Access and has no per-workflow signature or secret. Guessing a name-derived workflow ID permits arbitrary workflow dispatch, while chat_id bypasses ALLOWED_CHAT_IDS and can redirect notification steps.
Require a signed per-workflow secret, rate limiting, and allowlist validation for any supplied chat ID.
Also applies to: 79-100
🤖 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 `@worker/src/index.js` around lines 11 - 15, Secure the POST /webhooks/:id
handler around the workflow dispatch logic by authenticating each request with
the workflow’s configured signed secret, applying rate limiting, and rejecting
requests whose supplied chat_id is not in ALLOWED_CHAT_IDS. Ensure unauthorized
or invalid requests are denied before dispatch and cannot override the
allowlisted notification destination.
| const executionId = crypto.randomUUID(); | ||
|
|
||
| await env.DB.prepare( | ||
| `INSERT INTO executions (id, workflow_id, status, started_at) VALUES (?, ?, 'pending', ?)` | ||
| ).bind( | ||
| executionId, | ||
| id, | ||
| new Date().toISOString() | ||
| ).run(); | ||
|
|
||
| // Get chat ID from optional body or default to ALLOWED_CHAT_IDS | ||
| let chatId = null; | ||
| let triggerPayload = null; | ||
| try { | ||
| triggerPayload = await request.json().catch(() => null); | ||
| if (triggerPayload && triggerPayload.chat_id) { | ||
| chatId = triggerPayload.chat_id; | ||
| } | ||
| } catch (e) {} | ||
|
|
||
| if (!chatId) { | ||
| const allowList = (env.ALLOWED_CHAT_IDS || '') | ||
| .split(',') | ||
| .map((s) => s.trim()) | ||
| .filter(Boolean); | ||
| if (allowList.length > 0) { | ||
| chatId = allowList[0]; | ||
| } | ||
| } | ||
|
|
||
| const workerUrl = `${url.protocol}//${url.host}`; | ||
| await dispatchWorkflow(env, workflowObj, chatId, executionId, workerUrl, triggerPayload); |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Mark executions failed when dispatch fails.
Each path inserts a pending execution before calling GitHub, but dispatch errors never update that row. Failed webhook, Telegram, and cron executions therefore remain pending indefinitely.
Update status, finished_at, and the failure log in a dispatch catch before returning or continuing.
Also applies to: 624-658, 766-786
🤖 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 `@worker/src/index.js` around lines 69 - 100, The execution records created
before dispatchWorkflow must be marked failed when dispatch throws. In each
dispatch flow, including the shown path and the locations around the referenced
additional paths, wrap dispatchWorkflow in a catch that updates the matching
executionId with status failed, finished_at, and the failure log before
returning or continuing; preserve the existing success behavior.
| let triggerPayload = null; | ||
| try { | ||
| triggerPayload = await request.json().catch(() => null); | ||
| if (triggerPayload && triggerPayload.chat_id) { | ||
| chatId = triggerPayload.chat_id; | ||
| } | ||
| } catch (e) {} | ||
|
|
||
| if (!chatId) { | ||
| const allowList = (env.ALLOWED_CHAT_IDS || '') | ||
| .split(',') | ||
| .map((s) => s.trim()) | ||
| .filter(Boolean); | ||
| if (allowList.length > 0) { | ||
| chatId = allowList[0]; | ||
| } | ||
| } | ||
|
|
||
| const workerUrl = `${url.protocol}//${url.host}`; | ||
| await dispatchWorkflow(env, workflowObj, chatId, executionId, workerUrl, triggerPayload); |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Locate repository-dispatch payload construction and any existing size guards.
rg -n -C5 'client_payload|trigger_payload|dispatches|65536|64.?KB' .Repository: quickerup/telegram-workflow-bot
Length of output: 2371
🏁 Script executed:
#!/bin/bash
sed -n '1,140p' worker/src/index.js
echo '---'
sed -n '1088,1130p' worker/src/index.js
echo '---'
rg -n 'ALLOWED_CHAT_IDS|executionId|dispatchWorkflow|repository_dispatch|repository dispatch|client_payload|payload size|64 KB|65536' worker scripts README.md .Repository: quickerup/telegram-workflow-bot
Length of output: 14381
🌐 Web query:
GitHub repository_dispatch client_payload 64 KB limit docs
💡 Result:
GitHub repository_dispatch events include a client_payload parameter that is subject to specific constraints to ensure successful delivery. If these limits are exceeded, GitHub will return an HTTP 422 Unprocessable Entity error with the message "client_payload is too large." [1][2] Key limitations for the client_payload parameter include: 1. Data Size Limit: While GitHub does not officially document a precise byte-count limit in its public API documentation, community and developer evidence indicates a hard size ceiling of approximately 64 KB [3]. Payloads exceeding this size will trigger the "client_payload is too large" error [1][4][2]. 2. Top-Level Property Limit: The JSON object provided in the client_payload is limited to a maximum of 10 top-level properties [5][6][3]. If you attempt to send a payload with more than 10 top-level keys, the API request will fail [5][6]. Best Practices for Handling These Limits: - Use Nested Objects: To work around the 10-property limit, you can encapsulate multiple data points within a single top-level object [5][7]. For example, instead of sending flat properties, you can wrap them: {"data": {"key1": "value1", "key2": "value2",...}} [5][3]. - Payload Truncation: If your data naturally exceeds the 64 KB limit, implement logic in your application to truncate or compress the payload before sending it [8][3]. A common strategy is to send only essential metadata within the dispatch event and have the receiving workflow fetch the full data content from an external source or API if necessary [1][3]. - Minimize Data: Ensure that you are not serializing unnecessary context or verbose data into the payload, as this is the most common cause of hitting the size threshold [1][9].
Citations:
- 1: bug: GitHub dispatch integration fails with 422 "client_payload is too large" for long prompts langfuse/langfuse#12588
- 2: Send design tokens to URL option does not work : Error 422 lukasoppermann/design-tokens#287
- 3: fix(worker): truncate oversized GitHub dispatch payloads langfuse/langfuse#13752
- 4: Client_payload weight issue lukasoppermann/design-tokens#155
- 5: https://github.com/peter-evans/repository-dispatch?tab=readme-ov-file
- 6: https://github.com/step-security/dispatch-workflow
- 7: https://github.com/marketplace/actions/repository-dispatch?version=v2.1.1
- 8: Handle "client_payload is too large" gr2m/release-notifier-action#138
- 9: client_payload is too large peter-evans/slash-command-dispatch#67
Check the repository_dispatch payload size before inserting the execution row. GitHub rejects oversized client_payloads, so large workflows or webhook bodies can fail after the pending execution is already created. Validate the serialized dispatch body first, or store the payload externally and pass a reference instead.
🤖 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 `@worker/src/index.js` around lines 81 - 100, Update the repository dispatch
handling around triggerPayload and dispatchWorkflow to validate the serialized
client_payload size before creating the pending execution row. Reject or
otherwise handle oversized payloads before dispatching, or replace the inline
payload with an external reference, while preserving normal dispatch behavior
for payloads within GitHub’s limit.
| // --- Telegram Event Triggers --- | ||
| if (env.DB) { | ||
| try { | ||
| const { results: workflows } = await env.DB.prepare( | ||
| `SELECT id, name FROM workflows` | ||
| ).all(); | ||
|
|
||
| for (const wf of workflows) { | ||
| const { results: nodesRows } = await env.DB.prepare( | ||
| `SELECT id, type, config FROM nodes WHERE workflow_id = ?` | ||
| ).bind(wf.id).all(); | ||
|
|
||
| const eventTriggers = nodesRows.filter(row => row.type === 'telegram_event_trigger'); | ||
| if (eventTriggers.length === 0) continue; | ||
|
|
||
| let shouldTrigger = false; | ||
| for (const trigger of eventTriggers) { | ||
| const config = JSON.parse(trigger.config); | ||
| if (config && config.event_type) { | ||
| const eventTypes = Array.isArray(config.event_type) ? config.event_type : [config.event_type]; | ||
| if (eventTypes.some(et => matchTelegramEvent(update, et))) { | ||
| shouldTrigger = true; | ||
| break; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if (shouldTrigger) { |
There was a problem hiding this comment.
🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win
Authorize the Telegram chat before dispatching event workflows.
This block runs before the allowlist check at Lines 671-683. Consequently, unauthorized users can trigger workflows, and non-message updates never reach the later authorization logic.
Resolve the originating chat first, enforce ALLOWED_CHAT_IDS, and do not substitute the first allowed chat for an unauthorized or unidentified source.
Also applies to: 634-658
🤖 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 `@worker/src/index.js` around lines 572 - 599, Before the Telegram
event-trigger workflow loop around matchTelegramEvent, resolve the originating
chat from the update and enforce ALLOWED_CHAT_IDS; skip dispatch when the source
is missing or unauthorized. Reuse the resolved authorized chat for downstream
processing, and remove any fallback that substitutes the first allowed chat,
including the related logic around lines 634-658.
| for (const wf of workflows) { | ||
| const { results: nodesRows } = await env.DB.prepare( | ||
| `SELECT id, type, config FROM nodes WHERE workflow_id = ?` | ||
| ).bind(wf.id).all(); | ||
|
|
||
| const eventTriggers = nodesRows.filter(row => row.type === 'telegram_event_trigger'); | ||
| if (eventTriggers.length === 0) continue; | ||
|
|
||
| let shouldTrigger = false; | ||
| for (const trigger of eventTriggers) { | ||
| const config = JSON.parse(trigger.config); | ||
| if (config && config.event_type) { | ||
| const eventTypes = Array.isArray(config.event_type) ? config.event_type : [config.event_type]; | ||
| if (eventTypes.some(et => matchTelegramEvent(update, et))) { | ||
| shouldTrigger = true; | ||
| break; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if (shouldTrigger) { | ||
| const { results: edgesRows } = await env.DB.prepare( | ||
| `SELECT source, target, source_handle FROM edges WHERE workflow_id = ?` | ||
| ).bind(wf.id).all(); | ||
|
|
||
| const nodes = nodesRows.map(row => ({ | ||
| id: row.id, | ||
| type: row.type, | ||
| position: { x: 0, y: 0 }, | ||
| config: JSON.parse(row.config) | ||
| })); | ||
|
|
||
| const edges = edgesRows.map(row => ({ | ||
| source: row.source, | ||
| target: row.target, | ||
| sourceHandle: row.source_handle || 'success' | ||
| })); | ||
|
|
||
| const workflowObj = { | ||
| id: wf.id, | ||
| name: wf.name, | ||
| nodes, | ||
| edges | ||
| }; | ||
|
|
||
| const executionId = crypto.randomUUID(); | ||
|
|
||
| await env.DB.prepare( | ||
| `INSERT INTO executions (id, workflow_id, status, started_at) VALUES (?, ?, 'pending', ?)` | ||
| ).bind( | ||
| executionId, | ||
| wf.id, | ||
| new Date().toISOString() | ||
| ).run(); | ||
|
|
||
| let chatId = null; | ||
| if (update.message && update.message.chat) { | ||
| chatId = update.message.chat.id; | ||
| } else if (update.edited_message && update.edited_message.chat) { | ||
| chatId = update.edited_message.chat.id; | ||
| } else if (update.my_chat_member && update.my_chat_member.chat) { | ||
| chatId = update.my_chat_member.chat.id; | ||
| } else if (update.chat_member && update.chat_member.chat) { | ||
| chatId = update.chat_member.chat.id; | ||
| } else if (update.chat_join_request && update.chat_join_request.chat) { | ||
| chatId = update.chat_join_request.chat.id; | ||
| } | ||
|
|
||
| if (!chatId) { | ||
| const allowList = (env.ALLOWED_CHAT_IDS || '') | ||
| .split(',') | ||
| .map((s) => s.trim()) | ||
| .filter(Boolean); | ||
| if (allowList.length > 0) { | ||
| chatId = allowList[0]; | ||
| } | ||
| } | ||
|
|
||
| const workerUrl = `${url.protocol}//${url.host}`; | ||
| await dispatchWorkflow(env, workflowObj, chatId, executionId, workerUrl, { telegram_update: update }); | ||
| } | ||
| } | ||
| } catch (err) { | ||
| console.error('Error matching telegram_event_trigger:', err); | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Isolate failures to the current workflow.
Both scans use one outer try, so malformed config or one failed GitHub dispatch aborts every subsequent workflow. Catch errors inside each for (const wf ...) iteration and continue processing the remaining workflows.
Also applies to: 723-790
🤖 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 `@worker/src/index.js` around lines 579 - 663, Move the error boundary from the
outer workflow scan into each `for (const wf of workflows)` iteration, wrapping
that workflow’s trigger matching, execution creation, chat ID resolution, and
`dispatchWorkflow` calls in its own try/catch. Log the workflow-specific failure
and continue to the next workflow so malformed configuration or dispatch errors
do not abort remaining workflows; apply the same per-workflow handling to the
additional scan around the corresponding workflow loop.
| if (node.type === 'cron_trigger' && typeof config.cron !== 'string') { | ||
| errors.push(`node ${i} (${node.id || 'unnamed'}): "cron_trigger" config needs a string "cron"`); | ||
| } | ||
| if (node.type === 'telegram_event_trigger') { | ||
| if (typeof config.event_type !== 'string' && !Array.isArray(config.event_type)) { | ||
| errors.push(`node ${i} (${node.id || 'unnamed'}): "telegram_event_trigger" config needs a string or array "event_type"`); | ||
| } | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Align every trigger validator with the runtime matchers.
The current contracts permit workflows that silently never fire and, for arrays containing non-strings, can abort Telegram event scanning.
worker/src/index.js#L858-L865: require valid cron syntax and non-empty supported Telegram strings; validate every array element.scripts/validate.js#L82-L89: enforce the same rules in CLI validation.workflows/workflow.schema.json#L132-L164: add cron constraints plus Telegram enums,minLength,minItems, anduniqueItems.
📍 Affects 3 files
worker/src/index.js#L858-L865(this comment)scripts/validate.js#L82-L89workflows/workflow.schema.json#L132-L164
🤖 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 `@worker/src/index.js` around lines 858 - 865, Align all trigger validation
with runtime matcher requirements: in worker/src/index.js lines 858-865 and
scripts/validate.js lines 82-89, require syntactically valid cron expressions
and non-empty supported Telegram event strings, validating every array element;
update workflows/workflow.schema.json lines 132-164 with matching cron
constraints and Telegram enum, minLength, minItems, and uniqueItems
restrictions.
| function matchCron(cron, date) { | ||
| const fields = cron.trim().split(/\s+/); | ||
| if (fields.length !== 5) return false; | ||
|
|
||
| const mins = date.getUTCMinutes(); | ||
| const hours = date.getUTCHours(); | ||
| const dayOfMonth = date.getUTCDate(); | ||
| const month = date.getUTCMonth() + 1; // getUTCMonth is 0-indexed (0 = January) | ||
| const dayOfWeek = date.getUTCDay(); // 0 = Sunday | ||
|
|
||
| const matchField = (field, value, minVal, maxVal) => { | ||
| if (field === '*') return true; | ||
|
|
||
| // Normalize dayOfWeek: 7 becomes 0 | ||
| if (maxVal === 6 && field === '7') { | ||
| field = '0'; | ||
| } | ||
|
|
||
| // Handle comma-separated values | ||
| const parts = field.split(','); | ||
| if (parts.length > 1) { | ||
| return parts.some(p => matchField(p, value, minVal, maxVal)); | ||
| } | ||
|
|
||
| // Handle step values, e.g., */5 or 1-10/2 | ||
| if (field.includes('/')) { | ||
| const [range, stepStr] = field.split('/'); | ||
| const step = parseInt(stepStr, 10); | ||
| if (isNaN(step)) return false; | ||
|
|
||
| let start = minVal; | ||
| let end = maxVal; | ||
| if (range !== '*') { | ||
| if (range.includes('-')) { | ||
| const [startStr, endStr] = range.split('-'); | ||
| start = parseInt(startStr, 10); | ||
| end = parseInt(endStr, 10); | ||
| } else { | ||
| start = parseInt(range, 10); | ||
| } | ||
| } | ||
| if (isNaN(start) || isNaN(end)) return false; | ||
| if (value < start || value > end) return false; | ||
| return (value - start) % step === 0; | ||
| } | ||
|
|
||
| // Handle ranges, e.g., 1-5 | ||
| if (field.includes('-')) { | ||
| const [startStr, endStr] = field.split('-'); | ||
| const start = parseInt(startStr, 10); | ||
| const end = parseInt(endStr, 10); | ||
| if (isNaN(start) || isNaN(end)) return false; | ||
| return value >= start && value <= end; | ||
| } | ||
|
|
||
| // Handle single number | ||
| const num = parseInt(field, 10); | ||
| return num === value; |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
ast-grep outline worker/src/index.js --match matchCron --view expanded
rg -n -C4 '\bmatchCron\s*\(|cron_trigger' .Repository: quickerup/telegram-workflow-bot
Length of output: 8055
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Inspect the full matcher and nearby validation/docs references.
sed -n '1195,1265p' worker/src/index.js
printf '\n---\n'
rg -n -C2 'cron|matchCron|cron_trigger' worker/src/index.js scripts test-api.js workflows/workflow.schema.json README.md
printf '\n=== behavior probe ===\n'
node <<'JS'
function matchCron(cron, date) {
const fields = cron.trim().split(/\s+/);
if (fields.length !== 5) return false;
const mins = date.getUTCMinutes();
const hours = date.getUTCHours();
const dayOfMonth = date.getUTCDate();
const month = date.getUTCMonth() + 1;
const dayOfWeek = date.getUTCDay();
const matchField = (field, value, minVal, maxVal) => {
if (field === '*') return true;
if (maxVal === 6 && field === '7') field = '0';
const parts = field.split(',');
if (parts.length > 1) return parts.some(p => matchField(p, value, minVal, maxVal));
if (field.includes('/')) {
const [range, stepStr] = field.split('/');
const step = parseInt(stepStr, 10);
if (isNaN(step)) return false;
let start = minVal;
let end = maxVal;
if (range !== '*') {
if (range.includes('-')) {
const [startStr, endStr] = range.split('-');
start = parseInt(startStr, 10);
end = parseInt(endStr, 10);
} else {
start = parseInt(range, 10);
}
}
if (isNaN(start) || isNaN(end)) return false;
if (value < start || value > end) return false;
return (value - start) % step === 0;
}
if (field.includes('-')) {
const [startStr, endStr] = field.split('-');
const start = parseInt(startStr, 10);
const end = parseInt(endStr, 10);
if (isNaN(start) || isNaN(end)) return false;
return value >= start && value <= end;
}
const num = parseInt(field, 10);
return num === value;
};
return matchField(fields[0], mins, 0, 59) &&
matchField(fields[1], hours, 0, 23) &&
matchField(fields[2], dayOfMonth, 1, 31) &&
matchField(fields[3], month, 1, 12) &&
matchField(fields[4], dayOfWeek, 0, 6);
}
const d = new Date(Date.UTC(2026, 0, 1, 0, 1, 0)); // Thu 00:01 UTC
const tests = [
['1foo * * * *', 'minute prefix should not match minute 1'],
['01 * * * *', 'leading zero should match minute 1?'],
['* * * * 7', 'weekday 7 should normalize to Sunday'],
['* * * * 8', 'weekday 8 should not match'],
['*/0 * * * *', 'zero step'],
['*/-5 * * * *', 'negative step'],
['1-5/0 * * * *', 'zero step in range'],
['1-5/2 * * * *', 'valid step range'],
['1,2foo * * * *', 'comma-separated malformed token'],
['* * * * *', 'wildcard'],
];
for (const [cron, label] of tests) {
try {
console.log(cron.padEnd(15), '=>', matchCron(cron, d), '::', label);
} catch (e) {
console.log(cron.padEnd(15), '=> THREW', e.message, '::', label);
}
}
JSRepository: quickerup/telegram-workflow-bot
Length of output: 8979
🏁 Script executed:
#!/bin/bash
set -euo pipefail
node <<'JS'
function matchCron(cron, date) {
const fields = cron.trim().split(/\s+/);
if (fields.length !== 5) return false;
const mins = date.getUTCMinutes();
const hours = date.getUTCHours();
const dayOfMonth = date.getUTCDate();
const month = date.getUTCMonth() + 1;
const dayOfWeek = date.getUTCDay();
const matchField = (field, value, minVal, maxVal) => {
if (field === '*') return true;
if (maxVal === 6 && field === '7') field = '0';
const parts = field.split(',');
if (parts.length > 1) return parts.some(p => matchField(p, value, minVal, maxVal));
if (field.includes('/')) {
const [range, stepStr] = field.split('/');
const step = parseInt(stepStr, 10);
if (isNaN(step)) return false;
let start = minVal;
let end = maxVal;
if (range !== '*') {
if (range.includes('-')) {
const [startStr, endStr] = range.split('-');
start = parseInt(startStr, 10);
end = parseInt(endStr, 10);
} else {
start = parseInt(range, 10);
}
}
if (isNaN(start) || isNaN(end)) return false;
if (value < start || value > end) return false;
return (value - start) % step === 0;
}
if (field.includes('-')) {
const [startStr, endStr] = field.split('-');
const start = parseInt(startStr, 10);
const end = parseInt(endStr, 10);
if (isNaN(start) || isNaN(end)) return false;
return value >= start && value <= end;
}
const num = parseInt(field, 10);
return num === value;
};
return (
matchField(fields[0], mins, 0, 59) &&
matchField(fields[1], hours, 0, 23) &&
matchField(fields[2], dayOfMonth, 1, 31) &&
matchField(fields[3], month, 1, 12) &&
matchField(fields[4], dayOfWeek, 0, 6)
);
}
const date = new Date(Date.UTC(2026, 0, 1, 0, 1, 0)); // minute=1, hour=0, day=1, month=1, dow=4
const cases = [
'* * * * *',
'1foo * * * *',
'01 * * * *',
'1-3foo * * * *',
'*/0 * * * *',
'*/-5 * * * *',
'1-5/0 * * * *',
'1-5/2 * * * *',
'1-foo * * * *',
'foo * * * *',
'1,2foo * * * *',
'7 * * * 7',
'8 * * * 8',
];
for (const cron of cases) {
try {
console.log(JSON.stringify({ cron, result: matchCron(cron, date) }));
} catch (e) {
console.log(JSON.stringify({ cron, threw: e.message }));
}
}
JSRepository: quickerup/telegram-workflow-bot
Length of output: 664
🏁 Script executed:
#!/bin/bash
set -euo pipefail
node <<'JS'
function matchCron(cron, date) {
const fields = cron.trim().split(/\s+/);
if (fields.length !== 5) return false;
const mins = date.getUTCMinutes();
const hours = date.getUTCHours();
const dayOfMonth = date.getUTCDate();
const month = date.getUTCMonth() + 1;
const dayOfWeek = date.getUTCDay();
const matchField = (field, value, minVal, maxVal) => {
if (field === '*') return true;
if (maxVal === 6 && field === '7') field = '0';
const parts = field.split(',');
if (parts.length > 1) return parts.some(p => matchField(p, value, minVal, maxVal));
if (field.includes('/')) {
const [range, stepStr] = field.split('/');
const step = parseInt(stepStr, 10);
if (isNaN(step)) return false;
let start = minVal;
let end = maxVal;
if (range !== '*') {
if (range.includes('-')) {
const [startStr, endStr] = range.split('-');
start = parseInt(startStr, 10);
end = parseInt(endStr, 10);
} else {
start = parseInt(range, 10);
}
}
if (isNaN(start) || isNaN(end)) return false;
if (value < start || value > end) return false;
return (value - start) % step === 0;
}
if (field.includes('-')) {
const [startStr, endStr] = field.split('-');
const start = parseInt(startStr, 10);
const end = parseInt(endStr, 10);
if (isNaN(start) || isNaN(end)) return false;
return value >= start && value <= end;
}
const num = parseInt(field, 10);
return num === value;
};
return (
matchField(fields[0], mins, 0, 59) &&
matchField(fields[1], hours, 0, 23) &&
matchField(fields[2], dayOfMonth, 1, 31) &&
matchField(fields[3], month, 1, 12) &&
matchField(fields[4], dayOfWeek, 0, 6)
);
}
const date = new Date(Date.UTC(2026, 0, 1, 0, 1, 0));
for (const cron of ['1foo * * * *', '*/0 * * * *', '*/-5 * * * *', '1-5/0 * * * *', '1-5/2 * * * *']) {
console.log(cron, '=>', matchCron(cron, date));
}
JSRepository: quickerup/telegram-workflow-bot
Length of output: 278
🏁 Script executed:
#!/bin/bash
set -euo pipefail
node <<'JS'
function matchCron(cron, date) {
const fields = cron.trim().split(/\s+/);
if (fields.length !== 5) return false;
const mins = date.getUTCMinutes();
const hours = date.getUTCHours();
const dayOfMonth = date.getUTCDate();
const month = date.getUTCMonth() + 1;
const dayOfWeek = date.getUTCDay();
const matchField = (field, value, minVal, maxVal) => {
if (field === '*') return true;
if (maxVal === 6 && field === '7') field = '0';
const parts = field.split(',');
if (parts.length > 1) return parts.some(p => matchField(p, value, minVal, maxVal));
if (field.includes('/')) {
const [range, stepStr] = field.split('/');
const step = parseInt(stepStr, 10);
if (isNaN(step)) return false;
let start = minVal;
let end = maxVal;
if (range !== '*') {
if (range.includes('-')) {
const [startStr, endStr] = range.split('-');
start = parseInt(startStr, 10);
end = parseInt(endStr, 10);
} else {
start = parseInt(range, 10);
}
}
if (isNaN(start) || isNaN(end)) return false;
if (value < start || value > end) return false;
return (value - start) % step === 0;
}
if (field.includes('-')) {
const [startStr, endStr] = field.split('-');
const start = parseInt(startStr, 10);
const end = parseInt(endStr, 10);
if (isNaN(start) || isNaN(end)) return false;
return value >= start && value <= end;
}
const num = parseInt(field, 10);
return num === value;
};
return (
matchField(fields[0], mins, 0, 59) &&
matchField(fields[1], hours, 0, 23) &&
matchField(fields[2], dayOfMonth, 1, 31) &&
matchField(fields[3], month, 1, 12) &&
matchField(fields[4], dayOfWeek, 0, 6)
);
}
const dates = [
new Date(Date.UTC(2026,0,1,0,1,0)),
new Date(Date.UTC(2026,0,1,0,2,0)),
new Date(Date.UTC(2026,0,1,0,3,0)),
new Date(Date.UTC(2026,0,1,0,4,0)),
new Date(Date.UTC(2026,0,1,0,5,0)),
];
const crons = ['*/-5 * * * *', '1-5/-2 * * * *', '1-5/-1 * * * *', '1/-2 * * * *', '*/2 * * * *'];
for (const cron of crons) {
console.log('CRON', cron);
for (const d of dates) {
console.log(' ', d.toISOString(), matchCron(cron, d));
}
}
JSRepository: quickerup/telegram-workflow-bot
Length of output: 1073
🏁 Script executed:
#!/bin/bash
set -euo pipefail
node <<'JS'
function matchCron(cron, date) {
const fields = cron.trim().split(/\s+/);
if (fields.length !== 5) return false;
const mins = date.getUTCMinutes();
const hours = date.getUTCHours();
const dayOfMonth = date.getUTCDate();
const month = date.getUTCMonth() + 1;
const dayOfWeek = date.getUTCDay();
const matchField = (field, value, minVal, maxVal) => {
if (field === '*') return true;
if (maxVal === 6 && field === '7') field = '0';
const parts = field.split(',');
if (parts.length > 1) return parts.some(p => matchField(p, value, minVal, maxVal));
if (field.includes('/')) {
const [range, stepStr] = field.split('/');
const step = parseInt(stepStr, 10);
if (isNaN(step)) return false;
let start = minVal;
let end = maxVal;
if (range !== '*') {
if (range.includes('-')) {
const [startStr, endStr] = range.split('-');
start = parseInt(startStr, 10);
end = parseInt(endStr, 10);
} else {
start = parseInt(range, 10);
}
}
if (isNaN(start) || isNaN(end)) return false;
if (value < start || value > end) return false;
return (value - start) % step === 0;
}
if (field.includes('-')) {
const [startStr, endStr] = field.split('-');
const start = parseInt(startStr, 10);
const end = parseInt(endStr, 10);
if (isNaN(start) || isNaN(end)) return false;
return value >= start && value <= end;
}
const num = parseInt(field, 10);
return num === value;
};
return matchField(fields[0], mins, 0, 59);
}
for (const minute of [1, 3, 5, 7, 9, 11]) {
const date = new Date(Date.UTC(2026, 0, 1, 0, minute, 0));
const result = matchCron('1-11/-2 * * * *', date);
console.log(minute, result);
}
JSRepository: quickerup/telegram-workflow-bot
Length of output: 212
Parse cron tokens strictly
parseInt() accepts prefixes, so malformed tokens like 1foo, 1-3foo, and 1,2foo can match as valid cron values. Negative steps are also accepted and produce unintended schedules. Validate complete tokens, range bounds, and require step > 0 before matching.
🤖 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 `@worker/src/index.js` around lines 1195 - 1252, Update matchCron’s matchField
helper to strictly validate cron tokens before matching: require complete
numeric tokens for single values, ranges, comma-separated parts, and step
components; reject malformed or out-of-range values; and require step to be
greater than zero. Preserve existing wildcard, day-of-week normalization, and
valid step/range matching behavior.
This contribution adds robust support for triggers beyond "user sent a command". It introduces: 1. Webhooks (public POST /webhooks/:workflowId endpoint), 2. Cron/schedule triggers (using Cloudflare Worker scheduled event with custom cron matcher), and 3. Telegram event triggers (edited message, new chat members, etc. as triggers). It includes full schema updates, validation support, and comprehensive integration test coverage.
PR created automatically by Jules for task 13231458649736649231 started by @aethelred-agent-factory
Summary by CodeRabbit
New Features
Improvements