Créer un chat d'assistance soutenu par Slack dans ColdFusion - Partie 5 : Publier des messages ColdFusion dans des fils Slack

Votre première conversation avec Slack

Enfin, on peut écrire du ColdFusion. L'architecture est terminée. La base de données existe. Slack est configuré. Le bot a les permissions nécessaires. L'Events API nous attend patiemment. Il est enfin temps de faire appuyer le bouton par le singe.

Étrangement, cela finit par être l'une des plus courtes parties du projet. Pas parce que ce n'est pas important, mais parce que nous avons passé les quatre articles précédents à le rendre simple.

Le parcours heureux est presque ennuyeux

Envoyer un message à Slack est étonnamment simple. Construisez du JSON. Faites un POST vers chat.postMessage. Inspectez la réponse. Rentrez chez vous en vous sentant comme un génie.

Conceptuellement, ça ressemble à ceci.

Browser
    │
    ▼
ColdFusion
    │
chat.postMessage
    │
    ▼
Slack

C'est vraiment tout ce qu'il y a. La complexité vient de tout ce qui entoure cet appel d'API.

Créer la conversation

Rappelez-vous de la partie 3 où chaque conversation a sa propre identité? Quand le visiteur envoie son tout premier message, nous créons la conversation avant même de penser à Slack.

Conversation

conversation_id
visitor_name
visitor_email
status = Active
created_date

Ensuite, nous enregistrons le message du client. Ce n'est qu'après que les deux opérations ont réussi que nous contactons Slack. Cet ordre est important. Si Slack disparaît pendant cinq minutes, votre application devrait quand même savoir que la conversation existe. Votre base de données est la source de vérité. Slack est simplement un autre participant.

Enfin, un peu de CFML

Commençons avec un composant de base que nous appellerons slack.cfc.

Cela nous donne un endroit propre pour l'intégration Slack au lieu d'éparpiller des appels cfhttp partout dans l'application comme si un raton laveur s'était introduit dans le code source. À ce stade, nous avons seulement besoin de deux propriétés: le jeton du bot et le canal par défaut. Le jeton du bot identifie le bot Slack qui enverra les messages, et le canal par défaut donne au composant un endroit où publier lorsqu'aucun canal précis n'est fourni.

C'est volontairement petit. C'est le but. Nous mettons en place les éléments dont nous savons déjà avoir besoin sans prétendre pouvoir prévoir chaque future fonctionnalité Slack que nous ajouterons éventuellement après que quelqu'un aura dit: "Est-ce qu'on pourrait aussi juste..."

component output="false" {

	property name="bot_token";
	property name="default_channel";

	public slack function init(
		required string bot_token,
		required string default_channel
	) {
		variables.bot_token = arguments.bot_token;
		variables.default_channel = arguments.default_channel;

		return this;
	}

}

Ce n'est pas beaucoup de code, et c'est intentionnel.

À ce stade, le composant devrait seulement connaître les éléments dont chaque requête Slack aura besoin: le jeton du bot et le canal par défaut. Nous ne réglons pas encore tous les futurs problèmes Slack. Nous créons simplement une frontière d'intégration propre afin que le reste de l'application n'ait pas besoin de savoir comment Slack fonctionne.

Le reste de l'application ne devrait pas se soucier des jetons Bearer, des URL d'API, des en-têtes, de la sérialisation JSON ou de l'habitude de Slack de renvoyer ok: false dans une réponse HTTP parfaitement réussie.

Tout ce désordre appartient ici.

Pensez à ce composant comme à la réception de Slack. Les autres parties de l'application peuvent s'approcher et dire: «Veuillez envoyer ce message. » Elles ne devraient pas avoir à savoir dans quel bâtiment se trouve Slack, quel badge permet d'entrer dans la sécurité, ni quel ascenseur sent légèrement la cigarette et le regret.

Avant de créer une fonction post_message, nous avons besoin d'un utilitaire de plus bas niveau que chaque méthode Slack pourra partager.

	private struct function call_slack_api(
		required string method,
		required struct payload
	) {
		var response = "";

		cfhttp(
			url = "https://slack.com/api/#arguments.method#",
			method = "POST",
			result = "response",
			timeout = 15
		) {
			cfhttpparam(
				type = "header",
				name = "Authorization",
				value = "Bearer #variables.bot_token#"
			);

			cfhttpparam(
				type = "header",
				name = "Content-Type",
				value = "application/json; charset=utf-8"
			);

			cfhttpparam(
				type = "body",
				value = serializeJSON( arguments.payload )
			);
		}

		if ( !isJSON( response.fileContent ) ) {
			throw(
				type = "Slack.InvalidResponse",
				message = "Slack returned a non-JSON response.",
				detail = response.fileContent
			);
		}

		var parsed = deserializeJSON( response.fileContent );

		parsed[ "_http_status_code" ] = response.statusCode;
		parsed[ "_raw_response" ] = response.fileContent;

		return parsed;
	}

Cela nous donne un seul endroit universel pour communiquer avec Slack.

Cela compte plus qu'il n'y paraît au premier abord. Chaque appel à l'API Slack nécessite la même plomberie de base: l'URL de l'API, le jeton Bearer, le type de contenu JSON, la charge utile sérialisée, l'analyse de la réponse et la gestion des erreurs lorsque Slack renvoie quelque chose d'inattendu.

En mettant tout cela dans call_slack_api, chaque méthode future peut se concentrer sur ce qu'elle fait réellement au lieu de recréer sans cesse la même cérémonie cfhttp. Publier des messages, mettre à jour des messages, ouvrir des conversations, récupérer les réponses dans un fil... tout cela peut utiliser le même utilitaire interne.

Cela nous donne aussi un seul endroit pour améliorer l'intégration plus tard. Besoin d'une meilleure journalisation? Ajoutez-la ici. Besoin d'une gestion des tentatives? Ajoutez-la ici. Besoin de mesures de performance? Ajoutez-les ici. Besoin de comprendre pourquoi Slack a renvoyé quelque chose d'étrange à 16 h 30 un vendredi? C'est ici que vous fixerez l'écran en remettant silencieusement en question vos choix de carrière.

Avec l'utilitaire d'API générique en place, la première méthode publique devient agréablement ennuyeuse. C'est exactement ce que nous voulons.

Le reste de l'application ne devrait pas appeler chat.postMessage directement. Il devrait appeler une méthode qui décrit ce qu'il veut faire - publier un message. Le composant peut se charger de traduire cela dans le JSON particulier de Slack, y compris le canal, l'ajout de thread_ts au besoin, l'appel à l'API et la vérification que Slack a bel et bien accepté le message.

Cette fonction gère les deux cas qui nous intéressent pour l'instant. S'il n'y a pas de thread_ts, elle publie un nouveau message de niveau supérieur dans le canal. Cela crée le fil Slack, et le ts retourné est enregistré sur l'enregistrement de la conversation. S'il y a un thread_ts, elle publie le message comme réponse dans le fil Slack existant.

Même fonction. Deux cas d'utilisation. Moins de code. Moins d'endroits où Future You pourra trouver quelque chose d'horrible.

	public struct function post_message(
		required string text,
		string channel = variables.default_channel,
		string thread_ts = ""
	) {
		var payload = {
			"channel" = arguments.channel,
			"text" = arguments.text
		};

		if ( len( trim( arguments.thread_ts ) ) ) {
			payload[ "thread_ts" ] = arguments.thread_ts;
		}

		var result = call_slack_api(
			method = "chat.postMessage",
			payload = payload
		);

		if ( !result.ok ) {
			throw(
				type = "Slack.PostMessageFailed",
				message = "Slack rejected chat.postMessage.",
				detail = serializeJSON( result )
			);
		}

		return result;
	}

Nous avons maintenant tout ce qu'il faut pour la partie sortante.

La méthode publique post_message construit la charge utile attendue par Slack, inclut thread_ts seulement lorsque nous répondons à un fil existant, envoie la requête par l'entremise de notre aide d'API partagée, et refuse de faire semblant que tout va bien si Slack renvoie ok: false.

Cette dernière partie est importante. Une réponse Slack échouée ne devrait pas ressembler à une opération réussie de l'application. Si Slack rejette le message, nous lançons une exception utile avec les détails de la réponse afin que le code appelant puisse la consigner, la réessayer, la mettre en file d'attente ou traiter la réalité d'une autre façon au lieu de s'aventurer joyeusement en production avec un faux sentiment de réussite.

À ce stade, le composant est volontairement simple, mais utile. Il peut envoyer un premier message, capturer le ts retourné et envoyer les futurs messages dans le même fil à l'aide de thread_ts.

Cela suffit pour construire la première moitié du pont.

L'INSERT le plus important que vous ferez

Une fois que Slack renvoie l'horodatage, mettez à jour votre enregistrement de conversation :

conversation_id

slack_channel

slack_thread_ts

Félicitations. Vous avez maintenant connecté la conversation de votre application à celle de Slack. À partir de ce moment, chaque réponse sait exactement où elle appartient. Pas de recherche. Pas de devinettes. Pas d'essais pour faire correspondre les clients par adresse courriel parce que quelqu'un a oublié d'enregistrer l'identifiant du fil.

La vie est belle.

Le ts retourné est trop important pour rester dans une variable locale pendant que nous nous félicitons. Il n'a pas non plus sa place dans slack.cfc. Ce composant devrait savoir comment parler à Slack. Il ne devrait pas connaître votre source de données, votre schéma, vos noms de tables, ni même savoir qu'une base de données existe. Cette responsabilité appartient à un service de plus haut niveau qui coordonne le flux de travail de l'application.

Ce service envoie le premier message, confirme que Slack l'a accepté et enregistre immédiatement le ts retourné pour la conversation sous slack_thread_ts.

Cette séparation est importante. Elle rend slack.cfc réutilisable tout en faisant de la mise à jour de la base de données une partie de l'opération plutôt qu'une tâche facultative que quelqu'un pourrait penser à faire plus tard.

Parce que « on enregistrera cet identifiant important plus tard » est la façon dont les identifiants importants disparaissent. L'opération critique appartient à un service de conversation qui coordonne les deux systèmes :

  1. Appeler slack.post_message().
  2. Confirmer que Slack a retourné ok: true.
  3. Enregistrer le ts retourné comme slack_thread_ts.
  4. Retourner le résultat Slack.

Créons rapidement un composant nommé conversation_service.cfc pour gérer cette interaction.

component output="false" {
	property name="slack";
	public conversation_service function init(
		required slack slack
	) {
		variables.slack = arguments.slack;
		return this;
	}
	public struct function create_slack_thread(
		required string conversation_id,
		required string message,
		string channel = ""
	) {
		var slack_arguments = {
			text = arguments.message
		};
		if ( len( trim( arguments.channel ) ) ) {
			slack_arguments.channel = arguments.channel;
		}
		var slack_result = variables.slack.post_message(
			argumentCollection = slack_arguments
		);
		queryExecute(
			"
				UPDATE conversation
				SET
					slack_channel = :slack_channel,
					slack_thread_ts = :slack_thread_ts
				WHERE conversation_id = :conversation_id
			",
			{
				slack_channel = {
					value = slack_result.channel,
					cfsqltype = "cf_sql_varchar"
				},
				slack_thread_ts = {
					value = slack_result.ts,
					cfsqltype = "cf_sql_varchar"
				},
				conversation_id = {
					value = arguments.conversation_id,
					cfsqltype = "cf_sql_varchar"
				}
			}
		);
		return slack_result;
	}
}

Utilisation du composant

Appeler le composant est agréablement banal.

conversation_service.create_slack_thread(
    conversation_id = conversation_id,
    message = "Hi! I need some help."
);

Sous le capot, le composant construit la charge utile, appelle Slack, valide la réponse et retourne le résultat. Si Slack crée un nouveau fil, le ts retourné devient le thread_ts que vous conserverez dans votre enregistrement de conversation.

C'est tout.

Chaque message après cela

Chaque message futur devient encore plus simple.

Il suffit d'inclure l'horodatage du fil de discussion.

{
	"channel": "C123456",
	"thread_ts": "1751637712.948372",
	"text": "Here's some additional information..."
}

Slack dépose discrètement le message dans le fil de discussion existant. Le personnel de soutien poursuit la conversation naturellement. Le client ne sait jamais que Slack existe... exactement comme prévu.

Remarquez ce qui s'est passé au cours des vingt dernières minutes. Nous n'avons pas seulement écrit assez de code pour envoyer un message Slack. Nous avons écrit assez de code pour que le reste de notre application n'ait plus jamais besoin de savoir comment fonctionne Slack. Dès maintenant, chaque partie de l'application parle à une API propre et bien définie au lieu d'une autre plateforme SaaS. C'est exactement à cet endroit que les intégrations doivent se trouver.

Cependant, HTTP 200 ne veut pas dire succès

Ça prend presque tout le monde une fois. Si Slack renvoie HTTP 200, cela signifie seulement que Slack a bien reçu votre requête. Cela ne veut pas dire que Slack l'a acceptée. Vérifiez toujours la réponse JSON.

Bien :

{ "ok": true }

Moins bien :

{ "ok": false, "error": "channel_not_found" }

Ou...

{ "ok": false, "error": "not_in_channel" }

Ou encore une des nombreuses autres façons créatives de Slack pour dire : « Je crains de ne pas pouvoir faire ça. » Vérifiez toujours si l'attribut ok est true ou false. Toujours. Le code d'état HTTP ne raconte que la moitié de l'histoire.

Vos journaux vont vous sauver

Tôt ou tard, quelqu'un dira : « Le client jure qu'il a envoyé un message. » Parfait. Maintenant, vous pouvez le prouver. Chaque appel d'API sortant devrait consigner :

  • ID de la conversation
  • Canal Slack
  • Horodatage du fil de discussion
  • Charge utile
  • Réponse de Slack
  • Horodatage
  • Succès ou échec
  • Nombre de tentatives

Le débogage en production devient énormément plus facile quand on lit des faits plutôt que de reconstruire l'histoire de mémoire. La mémoire est peu fiable. Les fichiers journaux sont merveilleusement obstinés.

Slack, c'est l'ordinateur de quelqu'un d'autre

C'est facile à oublier... quand vous appelez l'API de Slack, vous n'appelez pas une fonction. Vous parlez à un autre ordinateur, sur un autre réseau, appartenant à une autre entreprise, qui exécute un logiciel que vous n'avez pas écrit.

Ça fait beaucoup d'occasions pour que la mauvaise journée de quelqu'un d'autre devienne votre incident de production.

Parfois, Slack ne sera pas indisponible. Parfois, ce sera lent. Parfois, DNS décide que c'est aujourd'hui qu'il veut de l'attention. Parfois, Slack subit une panne. Parfois, votre FAI subit une panne. Parfois, Mercure est apparemment en rétrograde.

Votre application ne devrait pas paniquer chaque fois que l'infrastructure de quelqu'un d'autre passe une mauvaise après-midi.

Mettez-le en file d'attente

L'une des meilleures décisions architecturales que nous avons prises a été de refuser de faire attendre les clients pour Slack. La séquence devient :

Customer clicks Send
        │
        ▼
Save conversation
        │
        ▼
Save message
        │
        ▼
Return success to browser
        │
        ▼
Queue Slack delivery
        │
        ▼
Background worker posts to Slack

Remarquez qui n'attend plus ? Le client. De son point de vue, le message a été accepté immédiatement. Si Slack a besoin de deux secondes de plus, c'est votre problème. Ça ne devrait jamais devenir le problème de votre client.

Les files d'attente sont des voisines remarquablement polies.

Les tentatives de reprise doivent être intelligentes

Certains échecs méritent une autre tentative et d'autres non.

Un délai d'attente réseau temporaire ? Réessayez. Une limite de débit 429 ? Réessayez après que Slack vous le demande. Un jeton OAuth invalide ? Surtout, ne le réessayez pas cinquante-sept fois en espérant que Slack change d'avis.

Savoir quand ne pas réessayer est tout aussi important que savoir quand réessayer. Sinon, vous automatisez simplement la déception.

L'idempotence compte

Les systèmes distribués ont la fâcheuse habitude de créer de l'incertitude. Slack a-t-il reçu la requête ? La réponse s'est-elle perdue ? Le réseau a-t-il échoué à mi-chemin ? Personne ne le sait.

Concevez votre traitement sortant de manière à ce que le réessayage de la même opération ne produise pas de messages en double. Si votre worker plante à mi-chemin de la livraison, le redémarrer devrait être sûr. C'est le genre de fonctionnalité que personne ne remarque, jusqu'au jour où elle vous sauve.

La plus grande leçon

La plus grande surprise n'était pas Slack. C'était de découvrir à quel point peu de code parle réellement à Slack.

Presque tout ce que nous avons construit existe pour rendre cet unique appel d'API fiable. L'intégration elle-même est minuscule. L'ingénierie qui l'entoure, c'est là que le logiciel de production gagne sa vie. C'est vrai pour Slack. C'est vrai pour Stripe. C'est vrai pour Twilio. C'est vrai pour presque toutes les API tierces avec lesquelles vous allez un jour vous intégrer.

L'appel d'API est rarement la partie difficile. Le rendre fiable, oui.

La prochaine fois

Jusqu'à présent, nous avons seulement parlé à Slack. La prochaine fois, nous allons laisser Slack nous répondre. Nous recevrons des rappels de l'API Events, vérifierons les signatures de requête de Slack, nous protégerons contre les attaques par rejeu, gérerons les livraisons d'événements en double et acheminerons les réponses du soutien vers la bonne conversation.

Parce qu'accepter des requêtes HTTP aléatoires pour argent comptant a historiquement été considéré comme une mauvaise stratégie de carrière.