docs(03): create phase plan
Phase 03: Container Actions - 4 plans in 4 waves (sequential due to shared workflow file) - Ready for execution
This commit is contained in:
@@ -0,0 +1,310 @@
|
||||
---
|
||||
phase: 03-container-actions
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["03-01"]
|
||||
files_modified: [n8n-workflow.json]
|
||||
autonomous: true
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Telegram Trigger receives callback_query updates from inline buttons"
|
||||
- "Callback queries route to dedicated handler branch"
|
||||
- "No-match suggestions show 'Did you mean X?' with inline button"
|
||||
- "User can accept suggestion without retyping command"
|
||||
artifacts:
|
||||
- path: "n8n-workflow.json"
|
||||
provides: "Callback query handling and suggestion flow"
|
||||
contains: "Route Update Type"
|
||||
key_links:
|
||||
- from: "Telegram Trigger"
|
||||
to: "Switch node (Route Update Type)"
|
||||
via: "message or callback_query routing"
|
||||
pattern: "callback_query"
|
||||
- from: "HTTP Request node"
|
||||
to: "Telegram Bot API"
|
||||
via: "sendMessage with inline_keyboard"
|
||||
pattern: "api.telegram.org.*sendMessage"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Add callback query infrastructure and implement the "did you mean?" suggestion flow for no-match cases.
|
||||
|
||||
Purpose: Enable inline button interactions in Telegram. When a user's container name doesn't match exactly, show a suggestion with an inline button they can click to accept without retyping.
|
||||
|
||||
Output: Extended n8n workflow with callback handling and suggestion UI.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/home/luc/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/home/luc/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/03-container-actions/03-CONTEXT.md
|
||||
@.planning/phases/03-container-actions/03-RESEARCH.md
|
||||
@.planning/phases/03-container-actions/03-01-SUMMARY.md
|
||||
@n8n-workflow.json
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Configure Telegram Trigger for callback queries</name>
|
||||
<files>n8n-workflow.json</files>
|
||||
<action>
|
||||
Modify the Telegram Trigger node to receive both message and callback_query updates:
|
||||
|
||||
1. Find the Telegram Trigger node in the workflow
|
||||
2. Update the "Updates" field to include both types:
|
||||
- In n8n UI: Updates → ["message", "callback_query"]
|
||||
- In JSON: "updates": ["message", "callback_query"]
|
||||
|
||||
3. Add a new Switch node immediately after the Telegram Trigger and before the authentication IF node:
|
||||
- Name: "Route Update Type"
|
||||
- Mode: Rules
|
||||
- Rules:
|
||||
- Rule 1: `{{ $json.message }}` is not empty → Output "message"
|
||||
- Rule 2: `{{ $json.callback_query }}` is not empty → Output "callback_query"
|
||||
|
||||
4. Restructure connections:
|
||||
- Telegram Trigger → Route Update Type
|
||||
- Route Update Type (message) → IF User Authenticated (existing flow)
|
||||
- Route Update Type (callback_query) → new callback handler branch
|
||||
|
||||
For callback_query authentication, add a new IF node:
|
||||
- Name: "IF Callback Authenticated"
|
||||
- Condition: `{{ $json.callback_query.from.id }}` equals authorized user ID
|
||||
- True: continue to callback processing
|
||||
- False: no connection (silent ignore per CONTEXT.md)
|
||||
</action>
|
||||
<verify>
|
||||
1. Send a regular message → should route to message branch and work as before
|
||||
2. Workflow should not error on receiving callback_query updates
|
||||
</verify>
|
||||
<done>Telegram Trigger receives callback_query, updates route to appropriate branches</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Implement suggestion flow for no-match cases</name>
|
||||
<files>n8n-workflow.json</files>
|
||||
<action>
|
||||
Replace the placeholder "No Match" branch from Plan 03-01 with a suggestion flow:
|
||||
|
||||
1. **Find Closest Match** (Code node):
|
||||
After determining zero exact matches, find the closest container name:
|
||||
```javascript
|
||||
const query = $json.containerQuery.toLowerCase();
|
||||
const containers = $json.allContainers; // Full list from Docker
|
||||
const action = $json.action;
|
||||
const chatId = $json.chatId;
|
||||
|
||||
// Simple closest match: longest common substring or starts-with
|
||||
let bestMatch = null;
|
||||
let bestScore = 0;
|
||||
|
||||
for (const container of containers) {
|
||||
const name = container.Names[0].replace(/^\//, '').toLowerCase();
|
||||
// Score by: contains query, or query contains name, or Levenshtein-like
|
||||
let score = 0;
|
||||
if (name.includes(query)) score = query.length;
|
||||
else if (query.includes(name)) score = name.length * 0.8;
|
||||
else {
|
||||
// Simple: count matching characters
|
||||
for (let i = 0; i < Math.min(query.length, name.length); i++) {
|
||||
if (query[i] === name[i]) score++;
|
||||
}
|
||||
}
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestMatch = container;
|
||||
}
|
||||
}
|
||||
|
||||
if (!bestMatch || bestScore < 2) {
|
||||
return { json: { hasSuggestion: false, query, action, chatId } };
|
||||
}
|
||||
|
||||
const suggestedName = bestMatch.Names[0].replace(/^\//, '');
|
||||
const suggestedId = bestMatch.Id.substring(0, 12); // Short ID for callback_data
|
||||
|
||||
return {
|
||||
json: {
|
||||
hasSuggestion: true,
|
||||
query,
|
||||
action,
|
||||
chatId,
|
||||
suggestedName,
|
||||
suggestedId,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
2. **Check Suggestion** (IF node):
|
||||
- Condition: `{{ $json.hasSuggestion }}` equals true
|
||||
- True: send suggestion with button
|
||||
- False: send "no container found" message
|
||||
|
||||
3. **Build Suggestion Keyboard** (Code node, on True branch):
|
||||
```javascript
|
||||
const { chatId, query, action, suggestedName, suggestedId, timestamp } = $json;
|
||||
|
||||
// callback_data must be ≤64 bytes - use short keys
|
||||
// a=action (1 char: s=start, t=stop, r=restart)
|
||||
// c=container short ID
|
||||
// t=timestamp
|
||||
const actionCode = action === 'start' ? 's' : action === 'stop' ? 't' : 'r';
|
||||
const callbackData = JSON.stringify({ a: actionCode, c: suggestedId, t: timestamp });
|
||||
|
||||
return {
|
||||
json: {
|
||||
chat_id: chatId,
|
||||
text: `No container '<b>${query}</b>' found.\n\nDid you mean <b>${suggestedName}</b>?`,
|
||||
parse_mode: "HTML",
|
||||
reply_markup: {
|
||||
inline_keyboard: [
|
||||
[
|
||||
{ text: `Yes, ${action} ${suggestedName}`, callback_data: callbackData },
|
||||
{ text: "Cancel", callback_data: '{"a":"x"}' }
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
4. **Send Suggestion** (HTTP Request node):
|
||||
- Method: POST
|
||||
- URL: `https://api.telegram.org/bot{{ $credentials.telegramApi.accessToken }}/sendMessage`
|
||||
- Body Content Type: JSON
|
||||
- Body: `{{ JSON.stringify($json) }}`
|
||||
|
||||
5. **No Suggestion Message** (Telegram Send Message, on False branch):
|
||||
- Chat ID: `{{ $json.chatId }}`
|
||||
- Text: `No container found matching '{{ $json.query }}'`
|
||||
</action>
|
||||
<verify>
|
||||
1. "stop nonexistent" → should show "No container found" (no suggestion if nothing close)
|
||||
2. "stop plx" when "plex" exists → should show "Did you mean plex?" with button
|
||||
3. Verify button appears and is clickable (don't click yet - callback handling in next task)
|
||||
</verify>
|
||||
<done>No-match cases show suggestion with inline button when a close match exists</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Handle suggestion callback and execute action</name>
|
||||
<files>n8n-workflow.json</files>
|
||||
<action>
|
||||
Add callback processing for suggestion buttons on the callback_query branch:
|
||||
|
||||
1. **Parse Callback Data** (Code node, after IF Callback Authenticated):
|
||||
```javascript
|
||||
const callback = $json.callback_query;
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(callback.data);
|
||||
} catch (e) {
|
||||
data = { a: 'x' }; // Treat parse error as cancel
|
||||
}
|
||||
|
||||
const queryId = callback.id;
|
||||
const chatId = callback.message.chat.id;
|
||||
const messageId = callback.message.message_id;
|
||||
|
||||
// Check 2-minute timeout
|
||||
const TWO_MINUTES = 120000;
|
||||
const isExpired = data.t && (Date.now() - data.t > TWO_MINUTES);
|
||||
|
||||
// Decode action
|
||||
const actionMap = { s: 'start', t: 'stop', r: 'restart', x: 'cancel' };
|
||||
const action = actionMap[data.a] || 'cancel';
|
||||
|
||||
return {
|
||||
json: {
|
||||
queryId,
|
||||
chatId,
|
||||
messageId,
|
||||
action,
|
||||
containerId: data.c || null,
|
||||
expired: isExpired,
|
||||
isSuggestion: true, // Single container suggestion, not batch
|
||||
isCancel: action === 'cancel'
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
2. **Route Callback** (Switch node):
|
||||
- Rule 1: `{{ $json.isCancel }}` equals true → Cancel branch
|
||||
- Rule 2: `{{ $json.expired }}` equals true → Expired branch
|
||||
- Rule 3: Default → Execute branch
|
||||
|
||||
3. **Cancel Handler** (Telegram node):
|
||||
- Operation: Answer Query
|
||||
- Query ID: `{{ $json.queryId }}`
|
||||
- Text: "Cancelled"
|
||||
- Show Alert: false
|
||||
|
||||
Then HTTP Request to delete the suggestion message:
|
||||
- POST to `https://api.telegram.org/bot{{ $credentials.telegramApi.accessToken }}/deleteMessage`
|
||||
- Body: `{ "chat_id": {{ $json.chatId }}, "message_id": {{ $json.messageId }} }`
|
||||
|
||||
4. **Expired Handler**:
|
||||
- Answer callback query with "Confirmation expired. Please try again."
|
||||
- Delete the old message
|
||||
|
||||
5. **Execute from Callback** (Execute Command node):
|
||||
```javascript
|
||||
const containerId = $json.containerId;
|
||||
const action = $json.action;
|
||||
const timeout = (action === 'stop' || action === 'restart') ? '?t=10' : '';
|
||||
const cmd = `curl -s -o /dev/null -w "%{http_code}" --unix-socket /var/run/docker.sock -X POST 'http://localhost/v1.47/containers/${containerId}/${action}${timeout}'`;
|
||||
return { json: { cmd, containerId, action, queryId: $json.queryId, chatId: $json.chatId, messageId: $json.messageId } };
|
||||
```
|
||||
|
||||
6. **Parse and Respond** (Code node):
|
||||
- Check status code (204/304 = success)
|
||||
- Fetch container name for response message
|
||||
- Answer callback query
|
||||
- Delete suggestion message
|
||||
- Send success/failure message
|
||||
</action>
|
||||
<verify>
|
||||
1. "stop plx" → suggestion appears → click "Yes, stop plex" → container stops, suggestion message deleted
|
||||
2. "stop plx" → suggestion appears → click "Cancel" → suggestion deleted, "Cancelled" toast
|
||||
3. "stop plx" → wait 2+ minutes → click button → shows "expired" message
|
||||
</verify>
|
||||
<done>Clicking suggestion button executes the action and cleans up the UI</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
End-to-end callback flow verification:
|
||||
|
||||
1. Regular messages still work (status, echo, actions from Plan 01)
|
||||
2. "stop typo" when similar container exists → suggestion with button
|
||||
3. Click "Yes" → action executes, success message appears
|
||||
4. Click "Cancel" → suggestion dismissed
|
||||
5. Wait 2 minutes, click → "expired" message
|
||||
6. "stop nonexistent" with no close match → plain "not found" message
|
||||
|
||||
Import updated workflow and test all scenarios.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Telegram Trigger receives both messages and callback_queries
|
||||
- Suggestion buttons appear for typos/close matches
|
||||
- Clicking suggestion executes the action
|
||||
- Cancel button dismisses suggestion
|
||||
- Expired confirmations handled gracefully
|
||||
- Old messages cleaned up after interaction
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/03-container-actions/03-02-SUMMARY.md`
|
||||
</output>
|
||||
Reference in New Issue
Block a user