feat(webhook): ajout de la fonctionnalité d'envoi de webhooks

- Remplacement de l'action de compteur par une action d'envoi de webhook
- Ajout d'une interface utilisateur pour configurer les paramètres du webhook (URL, méthode, headers, corps)
- Implémentation des services de validation, de construction de requêtes et d'exécution de requêtes
- Ajout de la gestion des erreurs et des messages de validation
- Mise à jour du fichier README avec des instructions d'utilisation et des exemples de configuration
- Ajout de nouveaux icônes pour l'action d'envoi de webhook
This commit is contained in:
Mr¤KayJayDee
2025-07-10 15:15:12 +02:00
parent 22c2a5c3a6
commit a48df5509c
18 changed files with 1495 additions and 74 deletions

335
README.md Normal file
View File

@@ -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 <repository-url>
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

View File

@@ -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",

View File

@@ -1,19 +0,0 @@
<!DOCTYPE html>
<html>
<head lang="en">
<title>Increment Counter Settings</title>
<meta charset="utf-8" />
<script src="https://sdpi-components.dev/releases/v4/sdpi-components.js"></script>
</head>
<body>
<!--
Learn more about property inspector components at https://sdpi-components.dev/docs/components
-->
<sdpi-item label="Increment By">
<sdpi-range setting="incrementBy" min="1" max="5" step="1" default="1" showlabels></sdpi-range>
</sdpi-item>
</body>
</html>

View File

@@ -0,0 +1,137 @@
<!DOCTYPE html>
<html>
<head lang="en">
<title>Send Webhook Settings</title>
<meta charset="utf-8" />
<script src="https://sdpi-components.dev/releases/v4/sdpi-components.js"></script>
<style>
.validation-error {
color: #ff6b6b;
font-size: 12px;
margin-top: 4px;
padding: 4px;
background-color: #ffe6e6;
border-left: 3px solid #ff6b6b;
border-radius: 3px;
display: none;
}
.validation-success {
color: #51cf66;
font-size: 12px;
margin-top: 4px;
padding: 4px;
background-color: #e6ffe6;
border-left: 3px solid #51cf66;
border-radius: 3px;
display: none;
}
.button-group {
display: flex;
gap: 8px;
margin-top: 8px;
}
.flex-item {
flex: 1;
}
.json-container {
position: relative;
}
.verify-button-container {
margin-top: 16px;
text-align: center;
}
.config-status {
margin-top: 8px;
padding: 8px;
border-radius: 4px;
font-size: 12px;
display: none;
}
.config-status.success {
background-color: #e6ffe6;
color: #51cf66;
border: 1px solid #51cf66;
}
.config-status.error {
background-color: #ffe6e6;
color: #ff6b6b;
border: 1px solid #ff6b6b;
}
</style>
</head>
<body>
<!--
Learn more about property inspector components at https://sdpi-components.dev/docs/components
-->
<sdpi-item label="URL">
<sdpi-textfield setting="url" placeholder="https://webhook.site/your-unique-url" required></sdpi-textfield>
<div id="url-error" class="validation-error"></div>
</sdpi-item>
<sdpi-item label="HTTP Method">
<sdpi-select setting="method" default="GET">
<option value="GET">GET</option>
<option value="POST">POST</option>
<option value="PUT">PUT</option>
<option value="PATCH">PATCH</option>
<option value="DELETE">DELETE</option>
</sdpi-select>
</sdpi-item>
<sdpi-item label="Headers (JSON)">
<div class="json-container">
<sdpi-textarea id="headers-textarea" setting="headers" placeholder='{"Content-Type": "application/json"}'
rows="4"></sdpi-textarea>
<div class="button-group">
<sdpi-button id="beautify-headers-btn" class="flex-item">
Beautify JSON
</sdpi-button>
<sdpi-button id="validate-headers-btn" class="flex-item">
Validate JSON
</sdpi-button>
</div>
</div>
<div id="headers-error" class="validation-error"></div>
<div id="headers-success" class="validation-success"></div>
</sdpi-item>
<sdpi-item label="Body">
<div class="json-container">
<sdpi-textarea id="body-textarea" setting="body" placeholder='{"message": "Hello from Stream Deck!"}'
rows="6"></sdpi-textarea>
<div class="button-group">
<sdpi-button id="beautify-body-btn" class="flex-item">
Beautify JSON
</sdpi-button>
<sdpi-button id="validate-body-btn" class="flex-item">
Validate JSON
</sdpi-button>
</div>
</div>
<div id="body-error" class="validation-error"></div>
<div id="body-success" class="validation-success"></div>
</sdpi-item>
<div class="verify-button-container">
<sdpi-item>
<sdpi-button id="verify-config-btn">
Verify Configuration
</sdpi-button>
</sdpi-item>
<div id="config-status" class="config-status"></div>
</div>
<script src="send-webhook.js"></script>
</body>
</html>

View File

@@ -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();
}

View File

@@ -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": {

View File

@@ -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<CounterSettings> {
/**
* 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<CounterSettings>): void | Promise<void> {
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<CounterSettings>): Promise<void> {
// 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;
};

185
src/actions/send-webhook.ts Normal file
View File

@@ -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<WebhookSettings> {
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<WebhookSettings>): Promise<void> {
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<WebhookSettings>): Promise<void> {
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<WebhookSettings>): Promise<void> {
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<WebhookSettings>['action']
): Promise<boolean> {
// 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<WebhookSettings>['action']
): Promise<void> {
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();
}
}
}

View File

@@ -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");

View File

@@ -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(', ');
}
}

View File

@@ -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;
}
}

View File

@@ -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<string, string>;
}
/**
* 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<WebhookExecutionResult> {
try {
const requestOptions = this.requestBuilder.buildFetchOptions(webhookRequest);
const response = await fetch(webhookRequest.url, requestOptions);
const responseHeaders: Record<string, string> = {};
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'
};
}
}
}

View File

@@ -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<string, string> {
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<string, string> = {};
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;
}
}

View File

@@ -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<string, string>;
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;