6
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?

ServiceNowから公式LINE通知を飛ばしたい

Last updated at Posted at 2025-09-06

ServiceNowから公式LINE通知を飛ばしたい

はじめに

はじめまして、そのだかけると申します。
初投稿です。

今回は ServiceNowLINE Messaging API を組み合わせて
ServiceNow上のトリガーをもとに、LINEに向けて通知を飛ばすBotを作成したので、
その取り組みについて紹介します。


作成の経緯

ServiceNowからLINE通知を飛ばす方法についてQiita上に記事が無かったから。
これにつきます。

2025年3月末にLINE Notifyがサービスを終了してしまい、
Messaging APIにサービスが移行(?)されました。

今日現在でも利用できるLINEの外部連携に関する記事が少なく、
特にServiceNow関連はどれだけ探しても出てこなかったので、
ほな書いたるか~となった次第です。

あとは普段Qiitaでいろんな記事を見て知識を得る側だったので
たまには知見をおすそわけしようと思ったのもあります。

注意点とお断り

SNと外部アプリの連携について、私自身あまり知見が無いため
基本ChatGPTからサンプルコードを出力して動かしたものになります。

もちろん実際に動作するところまでは確認していますが、
スクリプトの書き方や基本設計的な部分について
気になる部分等あればリファクタリングしてご利用ください。

記事の編集リクエストやコメントでのご指摘もお待ちしております

今回やってみたこと

  • LINEのともだちをSN上にコンシューマーとして追加するイベントの作成
  • 任意のメッセージを任意のユーザーに送信する即席スクリプトの作成

上記の作成手順と、そもそものLINE連携の手順を紹介します。

LINE側の設定

公式アカウント作成

まずはLINEアカウントを用意しないと始まりませんので、アカウントを作りましょう。

私は以下の記事を参考にさせていただきました。
(大変分かりやすかったです、ありがとうございました)

トークンの取得

先ほどの記事を参考に
LINE Messaging APIで「チャンネルシークレット」を、
LINE Developers で「チャネルアクセストークン(長期)」を取得します。

※上記はメモって残しておいてください!

これでLINE公式アカウント側の準備はほとんど完了です。
あとでLINE Developersの画面を少し使うので、タブは残しておきましょう

ServiceNow側の設定

トークンをプロパティとして作成

システムプロパティに先ほど取得したやつを保管しておきます。
image.png

今回の取り組みはHackathon用のデモで使用する前提なのでガバセキュリティでもある程度OKですが、
念のため認証情報[discovery_credentials]テーブルにAPIキーとかで
保存しておくことをおススメします


イベントとスクリプトアクションの作成

まずイベントレコードを作成するため
「システムポリシー > イベント > レジストリ」に移動します。
[sysevent_register.list]でも可。
レコードを新規作成して、名前を適当に入力してください。
私は「アプリケーション名.line.user_id_captured」で作成しました。


次にスクリプトアクションレコードの作成です。

このスクリプトアクションでは
LINEに友達追加してくれた ユーザーからメッセージが来たら
コンシューマー[csm_consumer]テーブルにレコードを作成 する動作を担っています。

  • 名前[name] フィールドにLINEの表示名が入力されます。
  • LINE ID[u_line_id] フィールドにLINEの内部IDが入力されます。

LINE ID[u_line_id]はカスタムカラムです。
LINEの内部IDは「U」から始まる30文字程度の英数字の羅列になっているため、
string型でカラムをあらかじめ新規作成しておくことを推奨します。

「システムポリシー > イベント > スクリプトアクション」に移動します。
[sysevent_script_action.list]でも可。
レコード新規作成し、以下を入力します。

  • 名前:「Save LINE userId to csm_consumer」(変えてもOK)
  • イベント:さっき作成したイベントレコードを選択
  • スクリプト:以下を参照

環境ごとの変動値となるプロパティ名等は「☆」で囲んでいるので、
適宜書き換えてください。1か所あります

Save LINE userId to csm_consumer
(function() {
    try {
        // --- 受信JSON(parm1 or event.parm1) ---
        var raw = '';
        try {
            if (typeof parm1 !== 'undefined' && parm1) raw = parm1;
        } catch (e1) {}
        if (!raw && typeof event !== 'undefined' && event && event.parm1) raw = event.parm1;
        if (!raw) {
            gs.warn('[LINE SA] no parm1');
            return;
        }

        var payload;
        try {
            payload = JSON.parse(raw);
        } catch (e2) {
            gs.warn('[LINE SA] JSON parse failed: ' + e2);
            return;
        }

        // --- LINEプロフィール取得(1:1 / group / room 対応) ---
        function fetchLineProfile(src) {
            var token = gs.getProperty('☆チャンネルシークレットのプロパティ名☆', '');
            if (!token || !src || !src.userId) return null;

            var endpoint = 'https://api.line.me/v2/bot/profile/' + src.userId;
            if (src.type === 'group' && src.groupId)
                endpoint = 'https://api.line.me/v2/bot/group/' + src.groupId + '/member/' + src.userId;
            else if (src.type === 'room' && src.roomId)
                endpoint = 'https://api.line.me/v2/bot/room/' + src.roomId + '/member/' + src.userId;

            var rm = new sn_ws.RESTMessageV2();
            rm.setEndpoint(endpoint);
            rm.setHttpMethod('GET');
            rm.setRequestHeader('Authorization', 'Bearer ' + token);

            var res = rm.execute();
            if (res.getStatusCode() !== 200) {
                gs.warn('[LINE SA] profile GET ' + res.getStatusCode() + ' ' + res.getBody());
                return null;
            }
            try {
                return JSON.parse(res.getBody());
            } catch (e) {
                return null;
            }
        }

        var inserted = 0,
            updated = 0,
            dup = 0,
            errs = 0;

        (payload.events || []).forEach(function(ev) {
            var src = ev && ev.source;
            if (!(src && src.userId)) return;

            var uid = String(src.userId);
            var prof = fetchLineProfile(src); // {displayName, pictureUrl, language...}

            try {
                var gr = new GlideRecord('csm_consumer');
                gr.addQuery('u_line_id', uid);
                gr.setLimit(1);
                gr.query();

                if (gr.next()) {
                    // 既存レコード更新:first_name が空なら displayName を入れる
                    var touched = false;
                    if (gr.isValidField('first_name') && !gr.getValue('first_name') && prof && prof.displayName) {
                        gr.setValue('first_name', prof.displayName);
                        touched = true;
                    }
                    if (gr.isValidField('u_line_picture_url') && prof && prof.pictureUrl && !gr.getValue('u_line_picture_url')) {
                        gr.setValue('u_line_picture_url', prof.pictureUrl);
                        touched = true;
                    }
                    if (gr.isValidField('u_line_lang') && prof && prof.language && !gr.getValue('u_line_lang')) {
                        gr.setValue('u_line_lang', prof.language);
                        touched = true;
                    }
                    if (touched) {
                        gr.update();
                        updated++;
                    } else {
                        dup++;
                    }
                    return;
                }

                // 新規レコード作成
                gr.initialize();
                gr.setValue('u_line_id', uid);
                if (gr.isValidField('first_name')) {
                    gr.setValue('first_name', (prof && prof.displayName) ? prof.displayName : 'LINE User');
                }
                if (gr.isValidField('u_line_picture_url') && prof && prof.pictureUrl) {
                    gr.setValue('u_line_picture_url', prof.pictureUrl);
                }
                if (gr.isValidField('u_line_lang') && prof && prof.language) {
                    gr.setValue('u_line_lang', prof.language);
                }
                gr.insert() ? inserted++ : errs++;
            } catch (e3) {
                errs++;
                gs.error('[LINE SA] insert/update error: ' + e3);
            }
        });

        gs.info('[LINE SA] inserted=' + inserted + ' updated=' + updated + ' dup=' + dup + ' errs=' + errs);
    } catch (ex) {
        gs.error('[LINE SA] fatal: ' + ex);
    }
});

LINE API受発信用のレコード作成

よくわからんが必要らしいので作ります。

  1. フィルターナビゲーターで
    「システム Web サービス > スクリプト化 Web サービス > スクリプト化REST API」を探してください
    「sys_ws_definition.list」と入力してもOK!!
    image.png
     

  2. 新規を押して「名前」フィールドに名前を付けて、保存します
    image.png

  3. 関連リストの「リソース」から新規レコードを作成します。内容は以下を参照してください。

名前:任意のリソース名を入力してください。(「post」とかで良いとおもう)
HTTPメソッド:「POST」を選択

スクリプト:以下を参照

環境ごとの変動値となるプロパティ名等は「☆」で囲んでいるので、
適宜書き換えてください。2か所あります

スクリプト済みRESTリソース [スクリプト]フィールド
(function process(request, response) {
    // --- Base64 (UTF-8) を純JS実装:スコープ差異対策 ---
    function utf8Bytes(str) {
        var o = [],
            i = 0,
            c = 0;
        for (i = 0; i < str.length; i++) {
            c = str.charCodeAt(i);
            if (c < 128) {
                o.push(c);
            } else if (c < 2048) {
                o.push(192 | (c >> 6), 128 | (c & 63));
            } else if (c >= 55296 && c <= 56319 && i + 1 < str.length) {
                var c2 = str.charCodeAt(++i);
                var cp = ((c - 55296) << 10) + (c2 - 56320) + 65536;
                o.push(240 | (cp >> 18), 128 | ((cp >> 12) & 63), 128 | ((cp >> 6) & 63), 128 | (cp & 63));
            } else {
                o.push(224 | (c >> 12), 128 | ((c >> 6) & 63), 128 | (c & 63));
            }
        }
        return o;
    }

    function b64(bytes) {
        var cs = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",
            o = "",
            i = 0,
            l = bytes.length;
        for (i = 0; i < l; i += 3) {
            var b1 = bytes[i],
                b2 = (i + 1 < l) ? bytes[i + 1] : NaN,
                b3 = (i + 2 < l) ? bytes[i + 2] : NaN;
            var t = ((b1 & 255) << 16) | (((b2 || 0) & 255) << 8) | ((b3 || 0) & 255);
            var c1 = (t >> 18) & 63,
                c2 = (t >> 12) & 63,
                c3 = (t >> 6) & 63,
                c4 = t & 63;
            if (isNaN(b2)) {
                o += cs.charAt(c1) + cs.charAt(c2) + "==";
            } else if (isNaN(b3)) {
                o += cs.charAt(c1) + cs.charAt(c2) + cs.charAt(c3) + "=";
            } else {
                o += cs.charAt(c1) + cs.charAt(c2) + cs.charAt(c3) + cs.charAt(c4);
            }
        }
        return o;
    }

    function toB64(s) {
        return b64(utf8Bytes(s));
    }

    var secret = gs.getProperty('☆チャンネルシークレットを格納したプロパティ名☆', '');
    var sig = request.getHeader('x-line-signature') || request.getHeader('X-Line-Signature') || '';
    var raw = request.body.dataString || '';

    if (!secret) {
        response.setStatus(400);
        return {
            ok: false,
            reason: 'secret not set'
        };
    }
    if (!sig) {
        response.setStatus(401);
        return {
            ok: false,
            reason: 'signature header missing'
        };
    }
    if (!raw) {
        response.setStatus(400);
        return {
            ok: false,
            reason: 'empty body'
        };
    }

    // 署名検証(HMAC-SHA256 → Base64、鍵はBase64で渡す仕様)
    var enc = new GlideCertificateEncryption();
    var calc = enc.generateMac(toB64(secret), 'HmacSHA256', raw);
    if (calc !== sig) {
        response.setStatus(401);
        return {
            ok: false,
            reason: 'invalid signature'
        };
    }

    // ★ 署名OKならイベントに丸投げ(保存はScript Actionで実行ユーザー=system)
    gs.eventQueue('☆前述のイベント名☆', null, raw, null);

    response.setStatus(200);
    return {
        ok: true
    };
})(request, response);

その他フィールドはデフォルトのままでOK!

このタイミングで読み取り専用になっている「リソースパス」フィールドの値をコピーしておいてください。

これらが入力出来たら「送信」ボタンをクリックします。

もう一度LINE側の設定

さっきタブを閉じずに残しておいたLINE Developersのタブを開き、
Webhook URL欄の「編集」をクリックしてURLを以下のように設定してください。

https://YOUR_INSTANCE.service-now.com/YOUR_RESOURCE_PATH

あと「Webhookの利用」を有効状態(緑色)にしておいてください。
image.png

URLが設定できたら、「検証」ボタンをクリックしてServiceNowとの連携がうまくいっているか確認してください。
「成功」と出たら成功みたいです。(それはそう)
image.png

これで基本的な設定は完了です!

任意のメッセージを任意のユーザーに送信するスクリプト

ひと通り設定ができたので、実際にLINEでメッセージを送ってみましょう。

まずは作成した公式LINEにメッセージを送信して、コンシューマー[csm_consumer]レコードが作成されることを確認して下さい。

作成されたレコードのLINE ID[u_line_id]フィールドに値が入ったら、
バックグラウンドスクリプトに以下を貼り付けて「Run Script」してみましょう!

環境ごとの変動値となるプロパティ名等は「☆」で囲んでいるので、
適宜書き換えてください。

BackGroundScript
// messagingAPIキーを取得
var token = gs.getProperty('☆チャンネルアクセストークンのプロパティ名☆');

var line_id = '☆LINEのIDを入力☆'; // lineID
var str = "☆送信したいメッセージを入力☆"; // 通知メッセージ

// テキスト
var body = {
    to: line_id,
    messages: [{
        type: 'text',
        text: str
    }]
};

// LINEのAPIをたたくヤツ
try {
    var rm = new sn_ws.RESTMessageV2();
    rm.setEndpoint('https://api.line.me/v2/bot/message/push');
    rm.setHttpMethod('POST');
    rm.setRequestHeader('Content-Type', 'application/json');
    rm.setRequestHeader('Authorization', 'Bearer ' + token);
    rm.setRequestBody(JSON.stringify(body));

    var res = rm.execute();
    gs.info('[ConfirmDemo] status=' + res.getStatusCode() + ' body=' + res.getBody());
} catch (e) {
    gs.error('[ConfirmDemo] exception: ' + e);
}

IMG_3917.jpeg
や っ た ぜ 。

まずはテキストメッセージを送信することができました。

もちろん「テキストメッセージだけしか送れない」なんてことはありません。
前述のスクリプト内のbody部分を変えてあげればいろんな形式で送ることができます。

詳細は下記を参照ください。
【公式】Messaging APIリファレンス

試しに確認テンプレートを使ってみましょう。
bodyを定義していた部分を以下のように書き換えて送信してみます。

ConfirmTemplate
// テキスト
var body = {
    to: line_id,
    messages: [{
        "type": "template",
        "altText": "あなたはどっち派?", // 通知時に表示されるメッセージ
        "template": {
            "type": "confirm", // 確認テンプレートを使用する
            "text": "あなたはイヌ派?それともネコ派?", // トーク画面で表示されるテキストメッセージ
            "actions": [{
                    "type": "message",
                    "label": "イヌ派", // 選択肢1の表示値
                    "text": "イヌ派です!" // 選択した際の入力値
                },
                {
                    "type": "message",
                    "label": "ネコ派", // 選択肢2の表示値
                    "text": "ネコ派です!" // 選択した際の入力値
                }
            ]
        }
    }]
};

IMG_3918.jpeg
もちろんネコ派です。

画像カルーセルを使いたい場合はこんな感じ。

image_carousel
// テキスト
var body = {
    to: line_id,
    messages: [{
        "type": "template",
        "altText": "やっぱイヌもいいよね!", // 通知時に表示されるメッセージ
        "template": {
            "type": "image_carousel", // 画像カルーセルテンプレートを使用する
            "columns": [{
                    "imageUrl": "https://media.istockphoto.com/id/157653757/ja/%E3%82%B9%E3%83%88%E3%83%83%E3%82%AF%E3%83%95%E3%82%A9%E3%83%88/close-up-of-%E3%83%9C%E3%83%BC%E3%83%80%E3%83%BC%E3%82%B3%E3%83%AA%E3%83%BC1-%E5%B9%B4%E3%81%AE%E5%8F%A4%E3%81%84%E3%82%AB%E3%83%A1%E3%83%A9%E7%9B%AE%E7%B7%9A.jpg?s=2048x2048&w=is&k=20&c=hyn1PbBb32C3MBrYNpm2ZKj5f9tXppedsuHgO7B55D0=",
                    "action": {
                        "type": "message",
                        "label": "かわいい",
                        "data": "ブラック&ホワイトはやっぱいいね!"
                    }
                },
                {
                    "imageUrl": "https://cdn.pixabay.com/photo/2025/01/08/19/02/border-collie-9319990_1280.jpg",
                    "action": {
                        "type": "message",
                        "label": "かわいい",
                        "text": "チョコレート&ホワイトもいいね!"
                    }
                }
            ]
        }
    }]
};
}

IMG_3920.jpeg
ネコ派ですが、イヌならボーダーコリーが好きです

といった感じで公開されているテンプレートで簡単に通知が送れるようになりました!

おわりに

本記事では簡易的にバックグラウンドで動作するスクリプトを紹介しましたが
実際に使用する際はAPIをたたく部分をスクリプトインクルードに集約するなど、
保守性の高い状態にしておくことを強くおすすめします!

生成AIの台頭により、難しそうな外部連携もたたきの作成はサクッと終わるようになり、
自己学習のとっかかりになるのは非常にありがたいものですね。

6
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
6
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?