diff --git a/README.md b/README.md new file mode 100644 index 0000000..db9097d --- /dev/null +++ b/README.md @@ -0,0 +1,335 @@ +# Webhooks Trigger - Stream Deck Plugin + +Un plugin Stream Deck professionnel pour envoyer des requêtes HTTP webhook en appuyant sur un bouton. Construit avec le SDK officiel Elgato et TypeScript avec une architecture modulaire suivant les principes SOLID. + +## 🚀 Fonctionnalités + +- **Requêtes HTTP complètes** : Support de GET, POST, PUT, PATCH, DELETE +- **Configuration flexible** : URL, headers, body, méthode HTTP personnalisables +- **Validation JSON intégrée** : Validation en temps réel des headers et body JSON +- **Beautification JSON** : Formatage automatique du JSON avec indentation +- **Vérification de configuration** : Bouton de validation complète avec détection d'erreurs +- **Retour visuel** : Indication de succès/échec sur Stream Deck +- **Logging complet** : Logs détaillés pour debugging et monitoring +- **Interface utilisateur moderne** : UI propre avec composants SDPI +- **Architecture modulaire** : Services séparés suivant les principes DRY et SRP + +## 📦 Installation + +### Prérequis + +- Stream Deck software officiel installé +- Node.js 20+ et npm +- Stream Deck CLI (`npm install -g @elgato/cli`) + +### Installation développeur + +1. **Cloner le repository** + + ```bash + git clone + cd webhooks-trigger + ``` + +2. **Installer les dépendances** + + ```bash + npm install + ``` + +3. **Construire le plugin** + + ```bash + npm run build + ``` + +4. **Installer en mode développeur** + + ```bash + streamdeck dev + cd com.mr-kayjaydee.webhooks-trigger.sdPlugin + streamdeck link + ``` + +5. **Redémarrer le plugin (après modifications)** + ```bash + npm run restart + ``` + +## ⚙️ Configuration + +### Champs de configuration + +| Champ | Description | Requis | Exemple | +| --------------- | ----------------------------- | ------ | -------------------------------------- | +| **URL** | URL de destination du webhook | ✅ | `https://webhook.site/abc123` | +| **HTTP Method** | Méthode HTTP à utiliser | ✅ | `POST` | +| **Headers** | Headers HTTP au format JSON | ❌ | `{"Content-Type": "application/json"}` | +| **Body** | Corps de la requête | ❌ | `{"message": "Hello!"}` | + +> **Note** : Le titre du bouton est géré nativement par Stream Deck via l'interface standard. + +### Exemples de configuration + +#### Webhook simple GET + +``` +URL: https://api.example.com/webhook +Method: GET +Headers: {"Authorization": "Bearer token123"} +Body: (vide) +``` + +#### Webhook POST avec JSON + +``` +URL: https://webhook.site/abc123 +Method: POST +Headers: {"Content-Type": "application/json"} +Body: {"event": "button_pressed", "timestamp": "now"} +``` + +#### Notification Discord + +``` +URL: https://discord.com/api/webhooks/your-webhook-url +Method: POST +Headers: {"Content-Type": "application/json"} +Body: {"content": "Message depuis Stream Deck!"} +``` + +## 🛠️ Interface utilisateur + +### Boutons de validation + +- **Beautify JSON** : Formate automatiquement le JSON avec indentation propre +- **Validate JSON** : Vérifie la syntaxe JSON et affiche les erreurs +- **Verify Configuration** : Validation complète de toute la configuration + +### Messages de validation + +- **Erreurs** : Affichées en rouge sous les champs pendant 5 secondes +- **Succès** : Affichées en vert pendant 5 secondes +- **Validation automatique** : Vérification en temps réel lors de la saisie +- **Timeouts gérés** : Les timeouts précédents sont automatiquement effacés + +### Retour visuel Stream Deck + +- **✅ Checkmark vert** : Requête envoyée avec succès (status 200-299) +- **❌ X rouge** : Erreur (URL invalide, JSON malformé, erreur réseau) + +## 📋 Validation et règles + +### Validation URL + +- ✅ Protocoles supportés : `http://` et `https://` +- ✅ Format URL valide requis +- ❌ URLs relatives non supportées + +### Validation Headers + +- ✅ JSON object valide : `{"key": "value"}` +- ❌ Arrays non supportés : `["value1", "value2"]` +- ❌ Primitives non supportées : `"string"` ou `123` + +### Validation Body + +- ✅ JSON valide ou texte plain +- ✅ Vide autorisé (optionnel) +- ⚠️ Warning si body vide pour POST/PUT/PATCH +- ⚠️ Warning si body présent pour GET + +### Bonnes pratiques + +- **Content-Type recommandé** : Ajoutez `{"Content-Type": "application/json"}` pour les requêtes avec body JSON +- **Headers d'authentification** : Utilisez `{"Authorization": "Bearer token"}` pour l'auth +- **Validation avant utilisation** : Utilisez le bouton "Verify Configuration" avant la première utilisation + +## 🔧 Développement + +### Structure du projet + +``` +webhooks-trigger/ +├── src/ # Code source TypeScript +│ ├── actions/ +│ │ └── send-webhook.ts # Action principale du plugin +│ ├── services/ # Services modulaires (SOLID) +│ │ ├── validation-service.ts # Service de validation +│ │ ├── webhook-request-builder.ts # Construction des requêtes HTTP +│ │ ├── webhook-executor.ts # Exécution des requêtes +│ │ └── settings-manager.ts # Gestion des paramètres +│ ├── types/ +│ │ └── webhook-settings.ts # Types TypeScript et constantes +│ └── plugin.ts # Point d'entrée du plugin +├── com.mr-kayjaydee.webhooks-trigger.sdPlugin/ # Plugin Stream Deck +│ ├── manifest.json # Métadonnées du plugin +│ ├── ui/ +│ │ ├── send-webhook.html # Interface utilisateur +│ │ └── send-webhook.js # JavaScript frontend +│ ├── bin/ +│ │ ├── plugin.js # Code compilé (généré) +│ │ └── package.json # Config module ES (généré) +│ ├── imgs/ # Icônes et assets +│ │ ├── actions/send-webhook/ # Icônes de l'action +│ │ └── plugin/ # Icônes du plugin +│ └── logs/ # Logs du plugin +├── package.json # Dépendances et scripts +├── tsconfig.json # Configuration TypeScript +└── rollup.config.mjs # Configuration build +``` + +### Scripts disponibles + +```bash +npm run build # Compile TypeScript vers JavaScript +npm run watch # Mode développement avec watch et auto-restart +npm run restart # Build + redémarrage du plugin +``` + +### Architecture logicielle + +Le plugin suit une architecture modulaire avec les principes **SOLID** : + +#### **Services Backend (TypeScript)** + +- **`ValidationService`** : Validation des paramètres webhook +- **`WebhookRequestBuilder`** : Construction des requêtes HTTP +- **`WebhookExecutor`** : Exécution des requêtes HTTP +- **`SettingsManager`** : Gestion et normalisation des paramètres + +#### **Types et constantes** + +- **`WebhookSettings`** : Interface TypeScript pour les paramètres +- **`WEBHOOK_CONSTANTS`** : Constantes partagées (méthodes, protocoles) + +#### **Frontend (JavaScript)** + +- Interface utilisateur simple et efficace +- Validation en temps réel avec gestion des timeouts +- Beautification JSON intégrée +- Pas de duplication d'alertes rouge/vert + +### Logging + +Le plugin utilise le système de logging du SDK Stream Deck avec des scopes séparés : + +```typescript +// Logs disponibles dans : com.mr-kayjaydee.webhooks-trigger.sdPlugin/logs/ +const logger = streamDeck.logger.createScope("SendWebhook"); +logger.info("Message d'information"); +logger.debug("Message de debug"); +logger.error("Message d'erreur"); +``` + +**Localisation des logs** : `com.mr-kayjaydee.webhooks-trigger.sdPlugin/logs/com.mr-kayjaydee.webhooks-trigger.0.log` + +### Tests et debugging + +1. **Vérifier les logs** + + ```bash + tail -f com.mr-kayjaydee.webhooks-trigger.sdPlugin/logs/*.log + ``` + +2. **Mode développement avec auto-restart** + + ```bash + npm run watch + ``` + +3. **Tester avec webhook.site** + - Aller sur https://webhook.site + - Copier l'URL unique générée + - Configurer le plugin avec cette URL + - Vérifier les requêtes reçues sur le site + +4. **Redémarrer après modifications** + ```bash + npm run restart + ``` + +## 🐛 Dépannage + +### Problèmes courants + +**❌ Plugin ne s'installe pas** + +- Vérifier que Stream Deck software est fermé pendant l'installation +- Utiliser `streamdeck dev` pour activer le mode développeur +- Vérifier les permissions du dossier + +**❌ Requête échoue (X rouge)** + +- Vérifier l'URL dans un navigateur +- Valider le JSON des headers/body avec les boutons de validation +- Consulter les logs pour les détails d'erreur +- Vérifier la connectivité réseau + +**❌ Configuration ne se sauvegarde pas** + +- Vérifier la syntaxe JSON avec les boutons de validation +- Utiliser le bouton "Verify Configuration" +- Redémarrer Stream Deck si nécessaire + +**❌ Bouton reste gris** + +- Configuration incomplète (URL manquante) +- JSON headers/body invalide +- Consulter les logs pour les détails + +**❌ Build échoue** + +- Vérifier que Node.js 20+ est installé +- Supprimer `node_modules` et relancer `npm install` +- Vérifier les erreurs TypeScript + +### Support et logs + +Pour obtenir de l'aide : + +1. Activer les logs détaillés +2. Reproduire le problème +3. Consulter les logs dans `logs/com.mr-kayjaydee.webhooks-trigger.0.log` +4. Inclure les logs dans votre rapport de bug + +## 🏗️ Technologies utilisées + +- **Stream Deck SDK v1.0.0** : SDK officiel Elgato +- **TypeScript 5.2+** : Langage principal avec typage fort +- **Node.js 20** : Runtime JavaScript +- **Rollup** : Bundler pour la compilation +- **SDPI Components** : Composants UI Stream Deck + +## 📄 Licence + +Ce projet est sous licence MIT. Voir le fichier `LICENSE` pour plus de détails. + +## 🤝 Contribution + +Les contributions sont les bienvenues ! Merci de : + +1. Fork le projet +2. Créer une branche feature (`git checkout -b feature/amazing-feature`) +3. Commit avec le format : `feat(webhook): description en français` +4. Push vers la branche (`git push origin feature/amazing-feature`) +5. Ouvrir une Pull Request + +### Format des commits + +``` +type(scope): description en français + +Types autorisés : feat, fix, docs, refactor, test, chore +Exemples : +- feat(validation): ajout de la validation JSON en temps réel +- fix(ui): correction du bug d'affichage des erreurs +- docs(readme): mise à jour de la documentation +- refactor(services): simplification de l'architecture +``` + +## ✨ Remerciements + +- [Elgato Stream Deck SDK](https://docs.elgato.com/sdk/) pour l'excellent SDK +- [SDPI Components](https://sdpi-components.dev/) pour les composants UI +- La communauté Stream Deck pour les retours et suggestions diff --git a/com.mr-kayjaydee.webhooks-trigger.sdPlugin/imgs/actions/counter/icon.png b/com.mr-kayjaydee.webhooks-trigger.sdPlugin/imgs/actions/send-webhook/icon.png similarity index 100% rename from com.mr-kayjaydee.webhooks-trigger.sdPlugin/imgs/actions/counter/icon.png rename to com.mr-kayjaydee.webhooks-trigger.sdPlugin/imgs/actions/send-webhook/icon.png diff --git a/com.mr-kayjaydee.webhooks-trigger.sdPlugin/imgs/actions/counter/icon@2x.png b/com.mr-kayjaydee.webhooks-trigger.sdPlugin/imgs/actions/send-webhook/icon@2x.png similarity index 100% rename from com.mr-kayjaydee.webhooks-trigger.sdPlugin/imgs/actions/counter/icon@2x.png rename to com.mr-kayjaydee.webhooks-trigger.sdPlugin/imgs/actions/send-webhook/icon@2x.png diff --git a/com.mr-kayjaydee.webhooks-trigger.sdPlugin/imgs/actions/counter/key.png b/com.mr-kayjaydee.webhooks-trigger.sdPlugin/imgs/actions/send-webhook/key.png similarity index 100% rename from com.mr-kayjaydee.webhooks-trigger.sdPlugin/imgs/actions/counter/key.png rename to com.mr-kayjaydee.webhooks-trigger.sdPlugin/imgs/actions/send-webhook/key.png diff --git a/com.mr-kayjaydee.webhooks-trigger.sdPlugin/imgs/actions/counter/key@2x.png b/com.mr-kayjaydee.webhooks-trigger.sdPlugin/imgs/actions/send-webhook/key@2x.png similarity index 100% rename from com.mr-kayjaydee.webhooks-trigger.sdPlugin/imgs/actions/counter/key@2x.png rename to com.mr-kayjaydee.webhooks-trigger.sdPlugin/imgs/actions/send-webhook/key@2x.png diff --git a/com.mr-kayjaydee.webhooks-trigger.sdPlugin/manifest.json b/com.mr-kayjaydee.webhooks-trigger.sdPlugin/manifest.json index 733487a..1572467 100644 --- a/com.mr-kayjaydee.webhooks-trigger.sdPlugin/manifest.json +++ b/com.mr-kayjaydee.webhooks-trigger.sdPlugin/manifest.json @@ -1,26 +1,26 @@ { - "Name": "Webhooks-Trigger", + "Name": "Webhooks Trigger", "Version": "0.1.0.0", "Author": "Mr-KayJayDee", "Actions": [ { - "Name": "Counter", - "UUID": "com.mr-kayjaydee.webhooks-trigger.increment", - "Icon": "imgs/actions/counter/icon", - "Tooltip": "Displays a count, which increments by one on press.", - "PropertyInspectorPath": "ui/increment-counter.html", + "Name": "Send Webhook", + "UUID": "com.mr-kayjaydee.webhooks-trigger.send-webhook", + "Icon": "imgs/actions/send-webhook/icon", + "Tooltip": "Sends an HTTP webhook request when pressed.", + "PropertyInspectorPath": "ui/send-webhook.html", "Controllers": [ "Keypad" ], "States": [ { - "Image": "imgs/actions/counter/key", + "Image": "imgs/actions/send-webhook/key", "TitleAlignment": "middle" } ] } ], - "Category": "Webhooks-Trigger", + "Category": "Webhooks Trigger", "CategoryIcon": "imgs/plugin/category-icon", "CodePath": "bin/plugin.js", "Description": "Allows you to send webhooks using your Stream Deck", diff --git a/com.mr-kayjaydee.webhooks-trigger.sdPlugin/ui/increment-counter.html b/com.mr-kayjaydee.webhooks-trigger.sdPlugin/ui/increment-counter.html deleted file mode 100644 index ccc9498..0000000 --- a/com.mr-kayjaydee.webhooks-trigger.sdPlugin/ui/increment-counter.html +++ /dev/null @@ -1,19 +0,0 @@ - - - - - Increment Counter Settings - - - - - - - - - - - - \ No newline at end of file diff --git a/com.mr-kayjaydee.webhooks-trigger.sdPlugin/ui/send-webhook.html b/com.mr-kayjaydee.webhooks-trigger.sdPlugin/ui/send-webhook.html new file mode 100644 index 0000000..1b13fc9 --- /dev/null +++ b/com.mr-kayjaydee.webhooks-trigger.sdPlugin/ui/send-webhook.html @@ -0,0 +1,137 @@ + + + + + Send Webhook Settings + + + + + + + + + +
+
+ + + + + + + + + + + + +
+ +
+ + Beautify JSON + + + Validate JSON + +
+
+
+
+
+ + +
+ +
+ + Beautify JSON + + + Validate JSON + +
+
+
+
+
+ +
+ + + Verify Configuration + + +
+
+ + + + + \ No newline at end of file diff --git a/com.mr-kayjaydee.webhooks-trigger.sdPlugin/ui/send-webhook.js b/com.mr-kayjaydee.webhooks-trigger.sdPlugin/ui/send-webhook.js new file mode 100644 index 0000000..e9608a7 --- /dev/null +++ b/com.mr-kayjaydee.webhooks-trigger.sdPlugin/ui/send-webhook.js @@ -0,0 +1,333 @@ +/** + * Stream Deck Property Inspector JavaScript for Send Webhook Action + * Simple and focused - fixes timeout and duplicate alert issues + */ + +// Store timeout IDs to clear them properly +const validationTimeouts = {}; + +// Utility function to show validation messages with proper timeout handling +function showValidationMessage(elementId, message, isError = true, duration = 5000) { + const element = document.getElementById(elementId); + if (!element) return; + + // Clear existing timeout for this element + if (validationTimeouts[elementId]) { + clearTimeout(validationTimeouts[elementId]); + delete validationTimeouts[elementId]; + } + + element.textContent = message; + element.style.display = message ? 'block' : 'none'; + + // Set new timeout if duration > 0 + if (duration > 0 && message) { + validationTimeouts[elementId] = setTimeout(() => { + element.style.display = 'none'; + delete validationTimeouts[elementId]; + }, duration); + } +} + +// Hide validation message immediately +function hideValidationMessage(elementId) { + showValidationMessage(elementId, '', true, 0); +} + +// JSON validation function +function validateJSON(jsonString, fieldName) { + if (!jsonString || jsonString.trim() === '') { + return { isValid: true, message: `${fieldName} is empty (optional)` }; + } + + try { + const parsed = JSON.parse(jsonString); + + // Additional validation for headers - must be an object + if (fieldName === 'Headers' && (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))) { + return { + isValid: false, + message: 'Headers must be a JSON object (e.g., {"Content-Type": "application/json"})' + }; + } + + return { isValid: true, message: `Valid JSON`, data: parsed }; + } catch (error) { + return { + isValid: false, + message: `Invalid JSON: ${error.message}` + }; + } +} + +// JSON beautification function +function beautifyJSON(jsonString) { + if (!jsonString || jsonString.trim() === '') { + return { success: false, message: 'No content to beautify' }; + } + + try { + const parsed = JSON.parse(jsonString); + const beautified = JSON.stringify(parsed, null, 2); + return { success: true, beautified }; + } catch (error) { + return { success: false, message: `Cannot beautify: ${error.message}` }; + } +} + +// URL validation function +function validateURL(urlString) { + if (!urlString || urlString.trim() === '') { + return { isValid: false, message: 'URL is required' }; + } + + try { + const url = new URL(urlString.trim()); + + // Check for supported protocols + if (!['http:', 'https:'].includes(url.protocol)) { + return { + isValid: false, + message: 'URL must use HTTP or HTTPS protocol' + }; + } + + return { isValid: true, message: 'Valid URL' }; + } catch (error) { + return { + isValid: false, + message: 'Invalid URL format' + }; + } +} + +// Get current settings from the form +function getCurrentSettings() { + const urlField = document.querySelector('sdpi-textfield[setting="url"]'); + const methodField = document.querySelector('sdpi-select[setting="method"]'); + const headersField = document.querySelector('sdpi-textarea[setting="headers"]'); + const bodyField = document.querySelector('sdpi-textarea[setting="body"]'); + + return { + url: urlField ? urlField.value : '', + method: methodField ? methodField.value : 'GET', + headers: headersField ? headersField.value : '', + body: bodyField ? bodyField.value : '' + }; +} + +// Handle field validation - shows only one alert at a time +function handleFieldValidation(fieldName, displayName) { + const field = document.querySelector(`sdpi-textarea[setting="${fieldName}"]`); + if (!field) return; + + const validation = validateJSON(field.value, displayName); + const errorId = `${fieldName}-error`; + const successId = `${fieldName}-success`; + + if (validation.isValid) { + showValidationMessage(successId, validation.message, false); + hideValidationMessage(errorId); + } else { + showValidationMessage(errorId, validation.message, true); + hideValidationMessage(successId); + } +} + +// Handle JSON beautification +function handleJsonBeautify(fieldName) { + const field = document.querySelector(`sdpi-textarea[setting="${fieldName}"]`); + if (!field) return; + + const result = beautifyJSON(field.value); + const errorId = `${fieldName}-error`; + const successId = `${fieldName}-success`; + + if (result.success) { + field.value = result.beautified; + // Trigger change event to save settings + field.dispatchEvent(new Event('change', { bubbles: true })); + showValidationMessage(successId, 'JSON beautified successfully!', false); + hideValidationMessage(errorId); + } else { + showValidationMessage(errorId, result.message, true); + hideValidationMessage(successId); + } +} + +// Comprehensive configuration verification +function verifyConfiguration() { + const settings = getCurrentSettings(); + const errors = []; + const warnings = []; + + // Validate URL + const urlValidation = validateURL(settings.url); + if (!urlValidation.isValid) { + errors.push(`URL: ${urlValidation.message}`); + } + + // Validate Headers JSON + const headersValidation = validateJSON(settings.headers, 'Headers'); + if (!headersValidation.isValid) { + errors.push(`Headers: ${headersValidation.message}`); + } + + // Validate Body JSON (only if not empty) + if (settings.body && settings.body.trim() !== '') { + const bodyValidation = validateJSON(settings.body, 'Body'); + if (!bodyValidation.isValid) { + errors.push(`Body: ${bodyValidation.message}`); + } + } + + // Check method-specific requirements + const methodsWithBody = ['POST', 'PUT', 'PATCH']; + if (methodsWithBody.includes(settings.method)) { + if (!settings.body || settings.body.trim() === '') { + warnings.push(`${settings.method} requests typically include a body`); + } + } else if (settings.method === 'GET' && settings.body && settings.body.trim() !== '') { + warnings.push('GET requests typically do not include a body'); + } + + // Check for common header requirements + if (settings.body && settings.body.trim() !== '') { + try { + const headers = settings.headers ? JSON.parse(settings.headers) : {}; + if (!headers['Content-Type'] && !headers['content-type']) { + warnings.push('Consider adding Content-Type header when sending a body'); + } + } catch (e) { + // Headers validation already handled above + } + } + + showConfigurationResults(errors, warnings); +} + +// Show configuration verification results +function showConfigurationResults(errors, warnings) { + const statusElement = document.getElementById('config-status'); + if (!statusElement) return; + + let message = ''; + let className = ''; + + if (errors.length > 0) { + className = 'error'; + message = `Configuration Issues:\n${errors.join('\n')}`; + if (warnings.length > 0) { + message += `\n\nWarnings:\n${warnings.join('\n')}`; + } + } else if (warnings.length > 0) { + className = 'error'; // Show warnings as yellow/orange + message = `Configuration Warnings:\n${warnings.join('\n')}`; + } else { + className = 'success'; + message = '✓ Configuration is valid and ready to use!'; + } + + statusElement.className = `config-status ${className}`; + statusElement.textContent = message; + statusElement.style.display = 'block'; + + // Hide after 10 seconds for success, keep visible for errors + if (className === 'success') { + setTimeout(() => { + statusElement.style.display = 'none'; + }, 10000); + } +} + +// Event handlers setup +function setupEventHandlers() { + // Headers validation button + const validateHeadersBtn = document.getElementById('validate-headers-btn'); + if (validateHeadersBtn) { + validateHeadersBtn.addEventListener('click', () => { + handleFieldValidation('headers', 'Headers'); + }); + } + + // Headers beautify button + const beautifyHeadersBtn = document.getElementById('beautify-headers-btn'); + if (beautifyHeadersBtn) { + beautifyHeadersBtn.addEventListener('click', () => { + handleJsonBeautify('headers'); + }); + } + + // Body validation button + const validateBodyBtn = document.getElementById('validate-body-btn'); + if (validateBodyBtn) { + validateBodyBtn.addEventListener('click', () => { + handleFieldValidation('body', 'Body'); + }); + } + + // Body beautify button + const beautifyBodyBtn = document.getElementById('beautify-body-btn'); + if (beautifyBodyBtn) { + beautifyBodyBtn.addEventListener('click', () => { + handleJsonBeautify('body'); + }); + } + + // Configuration verification button + const verifyConfigBtn = document.getElementById('verify-config-btn'); + if (verifyConfigBtn) { + verifyConfigBtn.addEventListener('click', verifyConfiguration); + } + + // Auto-validate URL on change + const urlField = document.querySelector('sdpi-textfield[setting="url"]'); + if (urlField) { + urlField.addEventListener('change', () => { + const validation = validateURL(urlField.value); + if (!validation.isValid && urlField.value.trim() !== '') { + showValidationMessage('url-error', validation.message, true, 3000); + } else { + hideValidationMessage('url-error'); + } + }); + } + + // Auto-validate JSON fields on change + const headersField = document.querySelector('sdpi-textarea[setting="headers"]'); + if (headersField) { + headersField.addEventListener('change', () => { + if (headersField.value.trim() !== '') { + const validation = validateJSON(headersField.value, 'Headers'); + if (!validation.isValid) { + showValidationMessage('headers-error', validation.message, true, 3000); + hideValidationMessage('headers-success'); + } else { + hideValidationMessage('headers-error'); + } + } + }); + } + + const bodyField = document.querySelector('sdpi-textarea[setting="body"]'); + if (bodyField) { + bodyField.addEventListener('change', () => { + if (bodyField.value.trim() !== '') { + const validation = validateJSON(bodyField.value, 'Body'); + if (!validation.isValid) { + showValidationMessage('body-error', validation.message, true, 3000); + hideValidationMessage('body-success'); + } else { + hideValidationMessage('body-error'); + } + } + }); + } +} + +// Initialize when DOM is loaded +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', setupEventHandlers); +} else { + setupEventHandlers(); +} \ No newline at end of file diff --git a/package.json b/package.json index 45b45b5..87e490d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,8 @@ { "scripts": { "build": "rollup -c", - "watch": "rollup -c -w --watch.onEnd=\"streamdeck restart com.mr-kayjaydee.webhooks-trigger\"" + "watch": "rollup -c -w --watch.onEnd=\"streamdeck restart com.mr-kayjaydee.webhooks-trigger\"", + "restart": "npm run build && streamdeck restart com.mr-kayjaydee.webhooks-trigger" }, "type": "module", "devDependencies": { diff --git a/src/actions/increment-counter.ts b/src/actions/increment-counter.ts deleted file mode 100644 index 3cf4c29..0000000 --- a/src/actions/increment-counter.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { action, KeyDownEvent, SingletonAction, WillAppearEvent } from "@elgato/streamdeck"; - -/** - * An example action class that displays a count that increments by one each time the button is pressed. - */ -@action({ UUID: "com.mr-kayjaydee.webhooks-trigger.increment" }) -export class IncrementCounter extends SingletonAction { - /** - * The {@link SingletonAction.onWillAppear} event is useful for setting the visual representation of an action when it becomes visible. This could be due to the Stream Deck first - * starting up, or the user navigating between pages / folders etc.. There is also an inverse of this event in the form of {@link streamDeck.client.onWillDisappear}. In this example, - * we're setting the title to the "count" that is incremented in {@link IncrementCounter.onKeyDown}. - */ - override onWillAppear(ev: WillAppearEvent): void | Promise { - return ev.action.setTitle(`${ev.payload.settings.count ?? 0}`); - } - - /** - * Listens for the {@link SingletonAction.onKeyDown} event which is emitted by Stream Deck when an action is pressed. Stream Deck provides various events for tracking interaction - * with devices including key down/up, dial rotations, and device connectivity, etc. When triggered, {@link ev} object contains information about the event including any payloads - * and action information where applicable. In this example, our action will display a counter that increments by one each press. We track the current count on the action's persisted - * settings using `setSettings` and `getSettings`. - */ - override async onKeyDown(ev: KeyDownEvent): Promise { - // Update the count from the settings. - const { settings } = ev.payload; - settings.incrementBy ??= 1; - settings.count = (settings.count ?? 0) + settings.incrementBy; - - // Update the current count in the action's settings, and change the title. - await ev.action.setSettings(settings); - await ev.action.setTitle(`${settings.count}`); - } -} - -/** - * Settings for {@link IncrementCounter}. - */ -type CounterSettings = { - count?: number; - incrementBy?: number; -}; diff --git a/src/actions/send-webhook.ts b/src/actions/send-webhook.ts new file mode 100644 index 0000000..f18d08b --- /dev/null +++ b/src/actions/send-webhook.ts @@ -0,0 +1,185 @@ +import streamDeck from "@elgato/streamdeck"; +import { action, KeyUpEvent, SingletonAction, WillAppearEvent, DidReceiveSettingsEvent } from "@elgato/streamdeck"; + +// Types and interfaces +import { WebhookSettings } from '../types/webhook-settings'; + +// Services +import { ValidationService } from '../services/validation-service'; +import { WebhookRequestBuilder } from '../services/webhook-request-builder'; +import { WebhookExecutor } from '../services/webhook-executor'; +import { SettingsManager } from '../services/settings-manager'; + +/** + * Action class that sends HTTP webhook requests when the button is pressed. + * Follows SOLID principles with dependency injection and single responsibility. + */ +@action({ UUID: "com.mr-kayjaydee.webhooks-trigger.send-webhook" }) +export class SendWebhook extends SingletonAction { + private readonly logger = streamDeck.logger.createScope("SendWebhook"); + + // Services - following Dependency Injection principle + private readonly validationService: ValidationService; + private readonly requestBuilder: WebhookRequestBuilder; + private readonly webhookExecutor: WebhookExecutor; + private readonly settingsManager: SettingsManager; + + constructor() { + super(); + + // Initialize services + this.validationService = new ValidationService(); + this.requestBuilder = new WebhookRequestBuilder(); + this.webhookExecutor = new WebhookExecutor(this.requestBuilder); + this.settingsManager = new SettingsManager(); + } + + /** + * Initialize action with default settings when it becomes visible + */ + override async onWillAppear(ev: WillAppearEvent): Promise { + this.logger.debug("Action appearing", { + currentSettings: ev.payload.settings + }); + + try { + const defaultedSettings = this.settingsManager.applyDefaults(ev.payload.settings); + + // Update settings if defaults were applied + if (this.settingsManager.hasSettingsChanged(defaultedSettings, ev.payload.settings)) { + this.logger.info("Applying default settings"); + await ev.action.setSettings(defaultedSettings); + } + + this.logger.debug("Action initialization complete", { + finalSettings: defaultedSettings + }); + } catch (error) { + this.logger.error("Failed to initialize action", error); + } + } + + /** + * Handle settings updates from Property Inspector + */ + override async onDidReceiveSettings(ev: DidReceiveSettingsEvent): Promise { + this.logger.info("Settings updated from Property Inspector", { + settings: ev.payload.settings, + settingsSummary: this.settingsManager.getSettingsSummary(ev.payload.settings) + }); + + // Validate settings and log results + this.validateAndLogSettings(ev.payload.settings); + } + + /** + * Execute webhook when key is pressed + */ + override async onKeyUp(ev: KeyUpEvent): Promise { + this.logger.info("Webhook execution triggered"); + + try { + const settings = this.settingsManager.sanitizeSettings(ev.payload.settings); + + // Pre-execution validation + if (!this.validateBeforeExecution(settings, ev.action)) { + return; + } + + // Build and execute request + const webhookRequest = this.requestBuilder.buildRequest(settings); + + this.logger.debug("Executing webhook request", { + url: webhookRequest.url, + method: webhookRequest.method, + hasHeaders: Object.keys(webhookRequest.headers).length > 0, + hasBody: !!webhookRequest.body + }); + + const result = await this.webhookExecutor.executeRequest(webhookRequest); + + // Handle execution result + await this.handleExecutionResult(result, ev.action); + + } catch (error) { + this.logger.error("Webhook execution failed", error); + await ev.action.showAlert(); + } + } + + /** + * Validates settings and logs validation results + */ + private validateAndLogSettings(settings: WebhookSettings): void { + const validationLogger = this.logger.createScope("Validation"); + + try { + const validationResult = this.validationService.validateSettings(settings); + + if (validationResult.errors.length > 0) { + validationLogger.error("Settings validation errors:", validationResult.errors); + } + + if (validationResult.warnings.length > 0) { + validationLogger.warn("Settings validation warnings:", validationResult.warnings); + } + + if (validationResult.isValid && validationResult.warnings.length === 0) { + validationLogger.debug("Settings validation passed"); + } + } catch (error) { + validationLogger.error("Validation process failed", error); + } + } + + /** + * Validates settings before execution and shows alerts if invalid + */ + private async validateBeforeExecution( + settings: WebhookSettings, + action: KeyUpEvent['action'] + ): Promise { + // Check required settings + if (!this.settingsManager.hasRequiredSettings(settings)) { + this.logger.error("Missing required settings - URL is required"); + await action.showAlert(); + return false; + } + + // Comprehensive validation + const validationResult = this.validationService.validateSettings(settings); + + if (!validationResult.isValid) { + this.logger.error("Settings validation failed before execution", { + errors: validationResult.errors + }); + await action.showAlert(); + return false; + } + + return true; + } + + /** + * Handles webhook execution result and provides visual feedback + */ + private async handleExecutionResult( + result: import('../services/webhook-executor').WebhookExecutionResult, + action: KeyUpEvent['action'] + ): Promise { + if (result.success) { + this.logger.info("Webhook executed successfully", { + status: result.status, + statusText: result.statusText + }); + await action.showOk(); + } else { + this.logger.error("Webhook execution failed", { + status: result.status, + statusText: result.statusText, + error: result.error + }); + await action.showAlert(); + } + } +} \ No newline at end of file diff --git a/src/plugin.ts b/src/plugin.ts index 23089d4..664e99b 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,12 +1,19 @@ import streamDeck, { LogLevel } from "@elgato/streamdeck"; -import { IncrementCounter } from "./actions/increment-counter"; +import { SendWebhook } from "./actions/send-webhook"; -// We can enable "trace" logging so that all messages between the Stream Deck, and the plugin are recorded. When storing sensitive information -streamDeck.logger.setLevel(LogLevel.TRACE); +// Create a main plugin logger +const logger = streamDeck.logger.createScope("Plugin"); -// Register the increment action. -streamDeck.actions.registerAction(new IncrementCounter()); +// Enable debug logging for development +streamDeck.logger.setLevel(LogLevel.DEBUG); + +logger.info("Initializing Webhooks Trigger plugin"); + +// Register the send webhook action. +streamDeck.actions.registerAction(new SendWebhook()); +logger.info("Registered SendWebhook action"); // Finally, connect to the Stream Deck. streamDeck.connect(); +logger.info("Connected to Stream Deck"); diff --git a/src/services/settings-manager.ts b/src/services/settings-manager.ts new file mode 100644 index 0000000..0232dd8 --- /dev/null +++ b/src/services/settings-manager.ts @@ -0,0 +1,67 @@ +import { WebhookSettings, WEBHOOK_CONSTANTS } from '../types/webhook-settings'; + +/** + * Service responsible for managing webhook settings + * Follows Single Responsibility Principle - only handles settings management + */ +export class SettingsManager { + /** + * Applies default values to settings if not present + */ + applyDefaults(settings: WebhookSettings): WebhookSettings { + return { + method: WEBHOOK_CONSTANTS.DEFAULT_METHOD, + ...settings + }; + } + + /** + * Checks if settings have changed compared to previous settings + */ + hasSettingsChanged(current: WebhookSettings, previous: WebhookSettings): boolean { + return JSON.stringify(current) !== JSON.stringify(previous); + } + + /** + * Sanitizes settings by trimming whitespace and normalizing values + */ + sanitizeSettings(settings: WebhookSettings): WebhookSettings { + return { + url: settings.url?.trim(), + method: settings.method || WEBHOOK_CONSTANTS.DEFAULT_METHOD, + headers: settings.headers?.trim(), + body: settings.body?.trim() + }; + } + + /** + * Validates that all required settings are present + */ + hasRequiredSettings(settings: WebhookSettings): boolean { + return !!(settings.url && settings.url.trim() !== ""); + } + + /** + * Gets a human-readable summary of the settings + */ + getSettingsSummary(settings: WebhookSettings): string { + const sanitized = this.sanitizeSettings(settings); + const parts: string[] = []; + + if (sanitized.url) { + parts.push(`URL: ${sanitized.url}`); + } + + parts.push(`Method: ${sanitized.method}`); + + if (sanitized.headers) { + parts.push(`Headers: ${sanitized.headers.length} chars`); + } + + if (sanitized.body) { + parts.push(`Body: ${sanitized.body.length} chars`); + } + + return parts.join(', '); + } +} \ No newline at end of file diff --git a/src/services/validation-service.ts b/src/services/validation-service.ts new file mode 100644 index 0000000..ef4d2b4 --- /dev/null +++ b/src/services/validation-service.ts @@ -0,0 +1,207 @@ +import { ValidationResult, WebhookSettings, WEBHOOK_CONSTANTS } from '../types/webhook-settings'; + +/** + * Service responsible for validating webhook settings + * Follows Single Responsibility Principle - only handles validation + */ +export class ValidationService { + /** + * Validates complete webhook settings + */ + validateSettings(settings: WebhookSettings): ValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + // Validate URL + const urlValidation = this.validateUrl(settings.url); + if (!urlValidation.isValid) { + errors.push(`URL: ${urlValidation.message}`); + } + + // Validate headers + const headersValidation = this.validateHeaders(settings.headers); + if (!headersValidation.isValid) { + errors.push(`Headers: ${headersValidation.message}`); + } + + // Validate body JSON (if provided) + if (settings.body && settings.body.trim() !== "") { + const bodyValidation = this.validateJsonString(settings.body, 'Body'); + if (!bodyValidation.isValid) { + warnings.push(`Body: ${bodyValidation.message}`); + } + } + + // Validate method + const methodValidation = this.validateMethod(settings.method); + if (!methodValidation.isValid) { + errors.push(`Method: ${methodValidation.message}`); + } + + // Check method-specific requirements + const methodWarnings = this.validateMethodBodyCompatibility(settings.method, settings.body); + warnings.push(...methodWarnings); + + // Check for common header requirements + if (settings.body && settings.body.trim() !== "") { + const headerWarnings = this.validateHeaderRequirements(settings.headers); + warnings.push(...headerWarnings); + } + + return { + isValid: errors.length === 0, + errors, + warnings + }; + } + + /** + * Validates URL format and protocol + */ + private validateUrl(url?: string): { isValid: boolean; message: string } { + if (!url || url.trim() === "") { + return { isValid: false, message: "URL is required" }; + } + + try { + const urlObj = new URL(url.trim()); + + if (!WEBHOOK_CONSTANTS.SUPPORTED_PROTOCOLS.includes(urlObj.protocol as any)) { + return { + isValid: false, + message: "URL must use HTTP or HTTPS protocol" + }; + } + + return { isValid: true, message: "Valid URL" }; + } catch { + return { + isValid: false, + message: "Invalid URL format" + }; + } + } + + /** + * Validates headers JSON format + */ + private validateHeaders(headers?: string): { isValid: boolean; message: string } { + if (!headers || headers.trim() === "") { + return { isValid: true, message: "Headers are empty (optional)" }; + } + + return this.validateJsonObject(headers, 'Headers'); + } + + /** + * Validates HTTP method + */ + private validateMethod(method?: string): { isValid: boolean; message: string } { + if (!method || !WEBHOOK_CONSTANTS.VALID_METHODS.includes(method as any)) { + return { + isValid: false, + message: `Invalid method. Must be one of: ${WEBHOOK_CONSTANTS.VALID_METHODS.join(', ')}` + }; + } + + return { isValid: true, message: "Valid method" }; + } + + /** + * Validates JSON string format + */ + private validateJsonString(jsonString: string, fieldName: string): { isValid: boolean; message: string } { + if (!jsonString || jsonString.trim() === "") { + return { isValid: true, message: `${fieldName} is empty (optional)` }; + } + + try { + JSON.parse(jsonString); + return { isValid: true, message: "Valid JSON" }; + } catch (error) { + return { + isValid: false, + message: `Invalid JSON: ${error instanceof Error ? error.message : 'Unknown error'}` + }; + } + } + + /** + * Validates JSON object (not array or primitive) + */ + private validateJsonObject(jsonString: string, fieldName: string): { isValid: boolean; message: string } { + const jsonValidation = this.validateJsonString(jsonString, fieldName); + if (!jsonValidation.isValid) { + return jsonValidation; + } + + if (jsonString.trim() === "") { + return { isValid: true, message: `${fieldName} is empty (optional)` }; + } + + try { + const parsed = JSON.parse(jsonString); + + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return { + isValid: false, + message: `${fieldName} must be a JSON object (e.g., {"key": "value"})` + }; + } + + return { isValid: true, message: "Valid JSON object" }; + } catch { + return { isValid: false, message: "Invalid JSON format" }; + } + } + + /** + * Validates method and body compatibility + */ + private validateMethodBodyCompatibility(method?: string, body?: string): string[] { + const warnings: string[] = []; + + if (!method) return warnings; + + const hasBody = body && body.trim() !== ""; + + if (WEBHOOK_CONSTANTS.METHODS_WITH_BODY.includes(method as any)) { + if (!hasBody) { + warnings.push(`${method} requests typically include a body`); + } + } else if (method === "GET" && hasBody) { + warnings.push("GET requests typically do not include a body"); + } + + return warnings; + } + + /** + * Validates header requirements based on body content + */ + private validateHeaderRequirements(headers?: string): string[] { + const warnings: string[] = []; + + if (!headers || headers.trim() === "") { + warnings.push("Consider adding Content-Type header when sending a body"); + return warnings; + } + + try { + const parsedHeaders = JSON.parse(headers); + if (typeof parsedHeaders === 'object' && parsedHeaders !== null) { + const hasContentType = Object.keys(parsedHeaders).some( + key => key.toLowerCase() === 'content-type' + ); + + if (!hasContentType) { + warnings.push("Consider adding Content-Type header when sending a body"); + } + } + } catch { + // Headers validation is handled elsewhere + } + + return warnings; + } +} \ No newline at end of file diff --git a/src/services/webhook-executor.ts b/src/services/webhook-executor.ts new file mode 100644 index 0000000..5c3c52e --- /dev/null +++ b/src/services/webhook-executor.ts @@ -0,0 +1,49 @@ +import { WebhookRequest } from '../types/webhook-settings'; +import { WebhookRequestBuilder } from './webhook-request-builder'; + +/** + * Result of webhook execution + */ +export interface WebhookExecutionResult { + success: boolean; + status?: number; + statusText?: string; + error?: string; + responseHeaders?: Record; +} + +/** + * Service responsible for executing webhook HTTP requests + * Follows Single Responsibility Principle - only handles HTTP execution + */ +export class WebhookExecutor { + constructor(private readonly requestBuilder: WebhookRequestBuilder) { } + + /** + * Executes a webhook request + */ + async executeRequest(webhookRequest: WebhookRequest): Promise { + try { + const requestOptions = this.requestBuilder.buildFetchOptions(webhookRequest); + + const response = await fetch(webhookRequest.url, requestOptions); + + const responseHeaders: Record = {}; + response.headers.forEach((value, key) => { + responseHeaders[key] = value; + }); + + return { + success: response.ok, + status: response.status, + statusText: response.statusText, + responseHeaders + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred' + }; + } + } +} \ No newline at end of file diff --git a/src/services/webhook-request-builder.ts b/src/services/webhook-request-builder.ts new file mode 100644 index 0000000..1c9d40d --- /dev/null +++ b/src/services/webhook-request-builder.ts @@ -0,0 +1,114 @@ +import { WebhookSettings, WebhookRequest, WEBHOOK_CONSTANTS, HttpMethod } from '../types/webhook-settings'; + +/** + * Service responsible for building HTTP requests from webhook settings + * Follows Single Responsibility Principle - only handles request building + */ +export class WebhookRequestBuilder { + /** + * Builds a webhook request from settings + * @throws Error if settings are invalid or required fields are missing + */ + buildRequest(settings: WebhookSettings): WebhookRequest { + const url = this.extractUrl(settings); + const method = this.extractMethod(settings); + const headers = this.extractHeaders(settings); + const body = this.extractBody(settings, method); + + return { + url, + method, + headers, + body + }; + } + + /** + * Builds RequestInit object for fetch API + */ + buildFetchOptions(webhookRequest: WebhookRequest): RequestInit { + const options: RequestInit = { + method: webhookRequest.method, + headers: webhookRequest.headers + }; + + if (webhookRequest.body !== undefined) { + options.body = webhookRequest.body; + } + + return options; + } + + /** + * Extracts and validates URL from settings + */ + private extractUrl(settings: WebhookSettings): string { + if (!settings.url || settings.url.trim() === "") { + throw new Error("URL is required"); + } + + const trimmedUrl = settings.url.trim(); + + try { + const url = new URL(trimmedUrl); + + if (!WEBHOOK_CONSTANTS.SUPPORTED_PROTOCOLS.includes(url.protocol as any)) { + throw new Error(`URL must use HTTP or HTTPS protocol, got: ${url.protocol}`); + } + + return trimmedUrl; + } catch (error) { + if (error instanceof Error && error.message.includes('protocol')) { + throw error; + } + throw new Error(`Invalid URL format: ${trimmedUrl}`); + } + } + + /** + * Extracts method from settings with default fallback + */ + private extractMethod(settings: WebhookSettings): HttpMethod { + return settings.method || WEBHOOK_CONSTANTS.DEFAULT_METHOD; + } + + /** + * Extracts and parses headers from settings + */ + private extractHeaders(settings: WebhookSettings): Record { + if (!settings.headers || settings.headers.trim() === "") { + return {}; + } + + try { + const parsed = JSON.parse(settings.headers); + + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error("Headers must be a JSON object"); + } + + // Ensure all values are strings + const headers: Record = {}; + for (const [key, value] of Object.entries(parsed)) { + headers[key] = String(value); + } + + return headers; + } catch (error) { + throw new Error(`Invalid headers JSON: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + /** + * Extracts body based on method and settings + */ + private extractBody(settings: WebhookSettings, method: HttpMethod): string | undefined { + // Only include body for methods that support it + if (!WEBHOOK_CONSTANTS.METHODS_WITH_BODY.includes(method as any)) { + return undefined; + } + + // Return body if provided, even if empty string + return settings.body?.trim() || undefined; + } +} \ No newline at end of file diff --git a/src/types/webhook-settings.ts b/src/types/webhook-settings.ts new file mode 100644 index 0000000..1e20bd7 --- /dev/null +++ b/src/types/webhook-settings.ts @@ -0,0 +1,46 @@ +import { JsonValue } from "@elgato/streamdeck"; + +/** + * HTTP methods supported by the webhook action + */ +export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + +/** + * Settings for the Send Webhook action + */ +export interface WebhookSettings { + url?: string; + method?: HttpMethod; + headers?: string; + body?: string; + [key: string]: JsonValue; // Index signature for JsonObject compatibility +} + +/** + * Validation result interface + */ +export interface ValidationResult { + isValid: boolean; + errors: string[]; + warnings: string[]; +} + +/** + * Webhook request configuration + */ +export interface WebhookRequest { + url: string; + method: HttpMethod; + headers: Record; + body?: string; +} + +/** + * Constants for webhook validation and processing + */ +export const WEBHOOK_CONSTANTS = { + SUPPORTED_PROTOCOLS: ['http:', 'https:'] as const, + METHODS_WITH_BODY: ['POST', 'PUT', 'PATCH'] as const, + DEFAULT_METHOD: 'GET' as const, + VALID_METHODS: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const +} as const; \ No newline at end of file