4
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ApexでSlack通知の実装(ほぼChatGPT)

Last updated at Posted at 2025-11-30

たまには開発者っぽい記事を書こうということで今回は
ApexでSlack通知を実装する
を書きたいと思いますが、時代はAIですので
チャッピー(ChatGPT)にほぼ丸投げしました。

今年もSalesforceのアドベントカレンダーに参加です。
この記事に関連してTriggerエラーの時にAPIコールする方法も書く予定です(*'ω'*)

チャッピーに要件を投げる

投げた内容は下記のとおり(今回はサンプルの業務イメージ)

Salesforceでシステム開発しています。Slack通知をApexで実装する方法を教えてください。
通知するタイミングは商談のフェーズがClosed Wonになったときです。
通知する内容は商談が成約したこと、取引先名、商談名、商談所有者でお願いします。

すぐに回答を出してくれます。長々と続くので手順を切りながら解説します。

image.png

手順のとおり進めていく

①SlackでWebhooks URLを作る

※チャッピーの出力↓
image.png

Slackのアプリ管理から「Incoming Webhooks」をインストールして
image.png

Slack通知したいチャンネルを設定して
image.png

Webhooks URLを控える
image.png

②Salesforce に Remote Site 設定を行う

※チャッピーの出力↓
image.png

リモートサイトの設定でWebhook URLを設定する。ドメインまででいいかと
image.png

③Apexクラス(通知クラス)を作成

※チャッピーの出力↓
image.png

public class SlackNotifier {
    @future(callout=true)
    public static void sendOpportunityWonNotification(Id oppId) {

        Opportunity opp = [
            SELECT Id, Name, StageName, 
                   Account.Name,
                   Owner.Name
            FROM Opportunity
            WHERE Id = :oppId
        ];

        // Slackメッセージ(好きに整形可)
        String message = '🎉 *商談が成約しました!*\n'
            + '• *取引先*: ' + opp.Account.Name + '\n'
            + '• *商談名*: ' + opp.Name + '\n'
            + '• *商談所有者*: ' + opp.Owner.Name;

        // Slack Incoming Webhook
        String webhookUrl = 'https://hooks.slack.com/services/xxxx/yyyy/zzzz';

        HttpRequest req = new HttpRequest();
        req.setEndpoint(webhookUrl);
        req.setMethod('POST');
        req.setHeader('Content-Type', 'application/json');

        // Slackに送るJSON
        Map<String,Object> payload = new Map<String,Object>();
        payload.put('text', message);

        req.setBody(JSON.serialize(payload));

        Http http = new Http();
        http.send(req);
    }
}

このままでコンパイルできました。
webhookUrlの値は控えていたものに差し替えてください。
※カスタム設定かカスタムメタデータに外出ししたほういいですが、今回は割愛

④ Opportunity Trigger を作成

※チャッピーの出力↓
image.png

trigger OpportunityTrigger on Opportunity (after update) {
    for (Opportunity opp : Trigger.new) {
        Opportunity oldOpp = Trigger.oldMap.get(opp.Id);

        // フェーズがClosed Wonになった時のみ通知
        if (opp.StageName == 'Closed Won' &&
            oldOpp.StageName != 'Closed Won') {

            SlackNotifier.sendOpportunityWonNotification(opp.Id);
        }
    }
}

たぶんこのままでも動きますが、、

気になるところを指摘

OpportunityTriggerから直接SlackNotifierを呼ぶのではなく、トリガハンドラを介するようにしてほしいのと、update時だけではなくinsert時にも動くようにしてほしいです。

image.png

trigger OpportunityTrigger on Opportunity (after insert, after update) {
    if (Trigger.isAfter) {
        if (Trigger.isInsert) {
            OpportunityTriggerHandler.afterInsert(Trigger.new);
        }
        if (Trigger.isUpdate) {
            OpportunityTriggerHandler.afterUpdate(Trigger.new, Trigger.oldMap);
        }
    }
}

public class OpportunityTriggerHandler {
    
    public static void afterInsert(List<Opportunity> newList) {
        List<Id> notifyIds = new List<Id>();

        for (Opportunity opp : newList) {
            // Insert 時点で Closed Won なら通知
            if (opp.StageName == 'Closed Won') {
                notifyIds.add(opp.Id);
            }
        }

        for (Id oppId : notifyIds) {
            SlackNotifier.sendOpportunityWonNotification(oppId);
        }
    }


    public static void afterUpdate(List<Opportunity> newList, Map<Id, Opportunity> oldMap) {
        List<Id> notifyIds = new List<Id>();

        for (Opportunity opp : newList) {
            Opportunity oldOpp = oldMap.get(opp.Id);

            // Closed Won に“変わった”時のみ通知
            if (opp.StageName == 'Closed Won' &&
                oldOpp.StageName != 'Closed Won') {
                notifyIds.add(opp.Id);
            }
        }

        for (Id oppId : notifyIds) {
            SlackNotifier.sendOpportunityWonNotification(oppId);
        }
    }
}

これもそのままでコンパイルとおりました。

動作確認

Insert時にClosed Won
image.png
Update時にClosed Won
image.png

これらを保存すると、、

image.png

Slackに通知来ました!!!
※取引先を紐づけるの忘れてましたのでnullになってます、、

ついでにテストクラスも

image.png
とやったんですが
出力されたテストクラスはテスト実行でNGになったのでいい感じにしておきました。
これでテストも成功してカバレッジもクリアします。

@isTest
private class OpportunityTriggerTest {

    // --- 共通 Slack Mock ---
    private class SlackMock implements HttpCalloutMock {
        public HTTPResponse respond(HTTPRequest req) {
            HttpResponse res = new HttpResponse();
            res.setStatusCode(200);
            res.setBody('ok');
            return res;
        }
    }

    // --- 共通テストデータ ---
    @testSetup
    static void setupData() {
        Test.setMock(HttpCalloutMock.class, new SlackMock());
        
        Account acc = new Account(Name = 'Test Account');
        insert acc;

        // Update 用(最初は Prospecting)
        Opportunity oppUpdate = new Opportunity(
            Name = 'Opp Update Test',
            StageName = 'Prospecting',
            CloseDate = Date.today(),
            AccountId = acc.Id
        );
        insert oppUpdate;

        // Stage 変更なし(Closed Won → Closed Won)
        Opportunity oppNoChange = new Opportunity(
            Name = 'Opp No Change',
            StageName = 'Closed Won',
            CloseDate = Date.today(),
            AccountId = acc.Id
        );
        insert oppNoChange;
    }

    @isTest
    static void testInsertClosedWon() {
        Test.setMock(HttpCalloutMock.class, new SlackMock());

        Account acc = [
            SELECT Id FROM Account
            WHERE Name = 'Test Account'
            LIMIT 1
        ];
        
        Opportunity oppInsert = new Opportunity(
            Name = 'Opp Insert ClosedWon',
            StageName = 'Closed Won',
            CloseDate = Date.today(),
            AccountId = acc.Id
        );


        Test.startTest();
        insert oppInsert;
        Test.stopTest();

        System.assert(true);
    }

    @isTest
    static void testUpdateToClosedWon() {
        Test.setMock(HttpCalloutMock.class, new SlackMock());

        Opportunity opp = [
            SELECT Id, StageName FROM Opportunity
            WHERE Name = 'Opp Update Test'
            LIMIT 1
        ];

        opp.StageName = 'Closed Won';

        Test.startTest();
        update opp;
        Test.stopTest();

        System.assert(true);
    }

    @isTest
    static void testDoNotNotifyWhenStageDoesNotChange() {
        Test.setMock(HttpCalloutMock.class, new SlackMock());

        Opportunity opp = [
            SELECT Id, StageName FROM Opportunity
            WHERE Name = 'Opp No Change'
            LIMIT 1
        ];

        // StageName は変更しない
        opp.Name = 'Opp No Change Updated';

        Test.startTest();
        update opp;
        Test.stopTest();

        System.assert(true);
    }
}

@isTest
private class SlackNotifierTest {

    // --- Slack Mock ---
    private class SlackMock implements HttpCalloutMock {
        public HTTPResponse respond(HTTPRequest req) {
            HttpResponse res = new HttpResponse();
            res.setStatusCode(200);
            res.setBody('ok');
            return res;
        }
    }

    // --- 共通テストデータ ---
    @testSetup
    static void setupData() {
        
        Account acc = new Account(Name = 'Notifier Test Account');
        insert acc;

        Opportunity opp = new Opportunity(
            Name = 'Notifier Test Opp',
            StageName = 'Value Proposition',
            CloseDate = Date.today(),
            AccountId = acc.Id
        );
        insert opp;
    }

    @isTest
    static void testSendOpportunityWonNotification() {

        Test.setMock(HttpCalloutMock.class, new SlackMock());

        Opportunity opp = [
            SELECT Id FROM Opportunity
            WHERE Name = 'Notifier Test Opp'
            LIMIT 1
        ];

        Test.startTest();
        SlackNotifier.sendOpportunityWonNotification(opp.Id);
        Test.stopTest();

        System.assert(true);
    }
}

チャッピーとの会話

最期らへんグダってますが、全文を共有します。
途中でチャッピーからも提案ありますが、トリガなので複数件想定で本来は実装するべきです。今回は1件(〜数件程度)の作成・更新を前提としたものとさせてください。

公開期限は2025年内で🎅

あとがき

AIが賢すぎて、なんかもう開発者いらねぇなぁと思う最近です。
自分も捨てられないように、PMスキルの向上と最新情報のキャッチアップは怠らないようにしていきたいです。
アドベントカレンダーもう1記事書く予定なのでそちらもお楽しみに(^_-)-☆

4
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?