Gmail 管理者検疫に関するアラートをSlackへ通知させたい!

カンムでコーポレートエンジニアをやっているhikkyです。

今回セキュリティチームからの依頼で、GoogleWorkspace(以下GWS)の機能である 「高度なフィッシングと不正なソフトウェアへの対策」 という機能の一部を有効化しました。 しかしこの機能通知がメールのみのため、Slackへ通知させるということを行いました。
この時の内容をせっかくなので、ブログ記事にしてみます。

必要システム

この手順を実施するためには以下のシステムが必要です。

  • GoogleWorkspace Enterpriseエディション以上
  • Slack有料プラン
  • Google Cloud
  •   Cloud Functions
  •   Cloud Scheduler
  •    BigQuery
  • Python3

事前準備

Google Cloud設定

こちらのドキュメントを参考に、プロジェクトを設定します。 support.google.com

GWSのログをBigQueryへエクスポート設定

GWSのプランがEnterprise以上の場合、GWSのログをBigQueryにエクスポートすることができます。
通常GWSのログは6ヶ月しか残すことができませんが、BigQueryにエクスポートすることで長期間ログの保存が可能になり、クエリでログを検索することができるようになります。

  • GWS管理コンソールへアクセスします。
  • 「レポート」→「BigQuery Export」を開きます。
  • Google BigQueryへのGoogle Workspaceデータのエクスポートを有効にします」にチェックを入れます。
  • 事前に設定した、GCPプロジェクトID、任意のデータセット名、ロケーション制限を設定し、【保存】をクリックします。

設定してから反映されるまでに、最大48時間かかることがあるため、しばらく待ちます。

設定したGCPプロジェクトのBigQueryを開き、データセットが作成されたかを確認します。
(ここでのデータセット名はgwslogです)
データセット下に、activityとusageが表示されていればOKです。
ただしデータが実際に流れてくるまでに時間がかかることがあるようです。

データが入ってきたかは、各テーブルでプレビューを表示させることで判断できます。

サンプルクエリの実行

BigQueryにGWSログデータが流れてきたことが確認できたら、サンプルクエリを実行して確認してみます。
こちらのページ にクエリ例が掲載されているので、試しに特権管理者の数を出力するクエリを実行してみます。

SELECT COUNT(DISTINCT user_email) as number_of_super_admins, date
FROM api_project_name.dataset_name.usage
WHERE accounts.is_super_admin = TRUE
GROUP BY 2
ORDER BY 2 DESC;

以下のような結果が返ってきて、日付単位での増減がわかりますね。

GWSメール検疫を設定する

  • GWS管理コンソールへアクセスします。
  • 「アプリ」→「Google Workspace」→「Gmail」→「検疫の管理」を開きます。
  • 【検疫の追加】をクリックします。 検疫されたメールを確認できるグループを指定したいので、Defaultを利用せず新しい検疫を追加します。 GWSでグループを作成し、検疫確認を実施するメンバーをグループに追加します。 【グループを管理】検疫確認を行うグループを設定します。

「メールが検疫されたときに定期的に通知する」にチェックをつけて保存します。

BigQueryで検疫されたメールを出力する

当初以下のようなクエリで一覧が取得できると思ったのですが、処理容量が大きく、課金額も大きくなってしまうため、処理容量を減らす必要があります。

SELECT TIMESTAMP_MICROS(gmail.event_info.timestamp_usec) as timestamp,
     gmail.message_info.subject,
     gmail.message_info.source.address as source,
     gmail.message_info.source.from_header_address as st_address,
     gmail.message_info.source.from_header_displayname as displayname,
     destination.address as destination,
     gmail.message_info.rfc2822_message_id
FROM gwslog.activity d, d.gmail.message_info.destination
WHERE
     EXISTS(SELECT 1 FROM d.gmail.message_info.triggered_rule_info ri, ri.consequence
          WHERE consequence.action = 3)
     AND TIMESTAMP_MICROS(gmail.event_info.timestamp_usec) >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 12 HOUR)
ORDER BY
  timestamp DESC;

必要なテーブルの詳細を確認すると、日分割テーブルとなっていました。

そのためリアルタイムでの通知は諦めて、一日一回の通知だけをさせる方針としました。 修正したクエリは以下です。

SELECT
    FORMAT_TIMESTAMP('%Y-%m-%dT%H:%M:%S%z', TIMESTAMP_MICROS(gmail.event_info.timestamp_usec), 'Asia/Tokyo') as timestamp,
    gmail.message_info.subject,
    gmail.message_info.source.address as source,
    gmail.message_info.source.from_header_address as source_address,
    gmail.message_info.source.from_header_displayname as displayname,
    destination.address as destination,
    gmail.message_info.rfc2822_message_id
FROM 
    `{os.environ["PROJECT_ID"]}.{os.environ["DATASET_ID"]}.{os.environ["TABLE_ID"]}`,
    UNNEST(gmail.message_info.destination) as destination
WHERE 
    _PARTITIONTIME = TIMESTAMP(TIMESTAMP_SUB(CURRENT_DATE(), INTERVAL 1 DAY))
    AND EXISTS (
        SELECT 1
        FROM UNNEST(gmail.message_info.triggered_rule_info) ri, UNNEST(ri.consequence) c
        WHERE c.action = 3
    )

Slackへ通知する

Cloud Functionsの設定

以下のようなpythonスクリプトを作成して、Cloud Functionsにデプロイします。

import os
import json
import requests
from google.cloud import bigquery
from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError

def query_to_slack(request, context):

    # Create BigQuery client
    client = bigquery.Client()

    # Define the query
    query = f"""
        SELECT
            FORMAT_TIMESTAMP('%Y-%m-%dT%H:%M:%S%z', TIMESTAMP_MICROS(gmail.event_info.timestamp_usec), 'Asia/Tokyo') as timestamp,
            gmail.message_info.subject,
            gmail.message_info.source.address as source,
            gmail.message_info.source.from_header_address as source_address,
            gmail.message_info.source.from_header_displayname as displayname,
            destination.address as destination,
            gmail.message_info.rfc2822_message_id
        FROM 
            `{os.environ["PROJECT_ID"]}.{os.environ["DATASET_ID"]}.{os.environ["TABLE_ID"]}`,
            UNNEST(gmail.message_info.destination) as destination
        WHERE 
            _PARTITIONTIME = TIMESTAMP(TIMESTAMP_SUB(CURRENT_DATE(), INTERVAL 1 DAY))
            AND EXISTS (
                SELECT 1
                FROM UNNEST(gmail.message_info.triggered_rule_info) ri, UNNEST(ri.consequence) c
                WHERE c.action = 3
            )
    """

    # Execute the query
    query_job = client.query(query)
    results = query_job.result()

    if ( results.total_rows > 0 ):
        #クエリ結果が1件以上あった場合にSlack通知
        send_slack_notification(results)


def format_results(results):
    blocks = []
    blocks.append({
            "type": "section",
            "text": {
                "type": "mrkdwn",
                "text": "検疫されたメールがあります。\n"
            }
        })
    for result in results:
        blocks.append({
            "type": "section",
            "fields": [
                {
                    "type": "mrkdwn",
                    "text": f"*Timestamp:*\n{result['timestamp']}"
                },
                {
                    "type": "mrkdwn",
                    "text": f"*Subject:*\n{result['subject']}"
                },
                {
                    "type": "mrkdwn",
                    "text": f"*Source:*\n{result['source']}"
                },
                {
                    "type": "mrkdwn",
                    "text": f"*Source Address:*\n{result['source_address']}"
                },
                {
                    "type": "mrkdwn",
                    "text": f"*Display Name:*\n{result['displayname']}"
                },
                {
                    "type": "mrkdwn",
                    "text": f"*Destination:*\n{result['destination']}"
                },
                {
                    "type": "mrkdwn",
                    "text": f"*Message ID:*\n{result['rfc2822_message_id']}"
                }
            ]
        })
        blocks.append({
            "type": "divider"
        })
    return blocks

def send_slack_notification(results):
    client = WebClient(token=os.environ['SLACK_API_TOKEN'])

    try:
        response = client.chat_postMessage(
            channel="#nf-quarantined",
            text="Quarantined Email Notification",
            blocks=format_results(results)
        )
    except SlackApiError as e:
        print(f"Error sending message: {e}")

Cloud Schedulerを利用して定期実行させる

以下のドキュメントを参考に、Cloud Schedulerを利用して一日一回定期実行するように設定します。 cloud.google.com

実行結果の確認

検疫メールがある場合に、指定した時間になると以下のようなSlack通知がきます。

最後に

検疫機能便利ではあるのですが、指定ドメインを検疫対象から外すといったことができません。
そのため弊社で利用しているツールからの通知メールも入ってしまうことがあります。
メールをあまり見ないので、Slackへ通知させることで検疫されたメールがあると気づけるので便利になったのではないでしょうか?
GWSログをBigQueryにエクスポートすることで、ログをクエリで検索することができるようになるので色々と幅は広がりそうです。

Azure ADを利用したDjango adminのSAML認証

SREの菅原です。

カンムのサービスのバックエンドは基本的にGoで書かれているのですが、一部の内部向け管理画面はPythonフレームワークDjangoで作成されています。

スタッフがDjango adminページにログインして各種オペレーションを行うのですが、adminページにログインするためにはDjango adminのアカウントが必要です。

社内で使う各種サービスのアカウントは基本的にはAzure Active Directoryを使ったSSOで一元管理されていますが、管理用WebアプリはSAML対応の実装をしておらず、前段のロードバランサー(ALB)でOIDC認証しているものの、adminページ自体のアカウントは管理用Webアプリで追加しなければいけない状態でした。

管理用Webアプリが独自にアカウント管理してしまうと、個別にアカウントを作成する手間が増え、Azure ADでの一元管理のメリットも薄れてしまいます。そこで管理用WebアプリでSAML認証ができるように改修をすることにしました。

django-saml2-auth → djangosaml2

DjangoSAML対応にはいくつかライブラリが存在します。

最初はgrafana/django-saml2-authを使って実装を進めていたのですが、動作確認を行ったところ以下のIssueの問題が発生しました

github.com

問題の対応にはdjango-saml2-auth自体の改修が必要そうであり、アップストリームへの修正の反映には時間がかかりそうだったため、django-saml2-authの利用は諦めIssueのコメントで触れられているdjangosaml2を使った実装に切り替えました。

djangosaml2を使った実装

djangosaml2の利用方法はドキュメントに詳しく書かれています。
※Azure AD側の設定については省略

まずは必要なライブラリを追加。

# Dockerfile
apt install libxmlsec1-dev pkg-config  xmlsec1
# requirements.txt
djangosaml2==1.5.5

settings.pyは以下のように修正。

INSTALLED_APPS = [
    # ...
    "djangosaml2",
]

MIDDLEWARE = [
    # ...
    'djangosaml2.middleware.SamlSessionMiddleware',
]

AUTHENTICATION_BACKENDS = [
    "django.contrib.auth.backends.ModelBackend",
    #"djangosaml2.backends.Saml2Backend",
    "apps.auth.saml2.ModifiedSaml2Backend",
]

SESSION_COOKIE_SECURE = True
LOGIN_URL = "/saml2/login/"
LOGIN_REDIRECT_URL = '/admin'
SESSION_EXPIRE_AT_BROWSER_CLOSE = True
SAML_IGNORE_LOGOUT_ERRORS = True
SAML_DJANGO_USER_MAIN_ATTRIBUTE = "username"
SAML_CREATE_UNKNOWN_USER = True

SAML_ATTRIBUTE_MAPPING = {
     "name": ("username",),
     "emailAddress": ("email",),
     "givenName": ("first_name",),
     "surname": ("last_name",),
}

SAML_METADATA_URL = "https://login.microsoftonline.com/..."

SAML_CONFIG = {
    "entityid": "https://my-admin.example.com/saml2/acs/",
    "service": {
        "sp": {
            "endpoints": {
                "assertion_consumer_service": [
                    ("https://my-admin.example.com/saml2/acs/", saml2.BINDING_HTTP_POST),
                ],
                "single_logout_service": [
                    ("https://my-admin.example.com/saml2/ls/", saml2.BINDING_HTTP_REDIRECT),
                    ("https://my-admin.example.com/saml2/ls/post", saml2.BINDING_HTTP_POST),
                ],
            },
            "want_response_signed": False,
        },
    },
    "metadata": {
        "remote": [
            {"url": SAML_METADATA_URL},
        ],
    },
    # "debug": 1,
}

urls.pyには/saml2/のurlpatternsを追加。

urlpatterns = [
    # ...
    url(r'^saml2/', include("djangosaml2.urls")),
]

Saml2Backendの拡張

SAMLの認証用のバックエンドSaml2Backendをそのまま使ってログインすると、何の権限もないユーザーがDjangoに作成されるので、Saml2Backendを拡張しとりあえず必要な権限を付与したグループに新しいユーザーを所属させるようにしました。

from djangosaml2.backends import Saml2Backend

from django.contrib.auth.models import Group


class ModifiedSaml2Backend(Saml2Backend):
    def save_user(self, user, *args, **kwargs):
        user.save()
        user_group = Group.objects.get(name="default")
        user.groups.add(user_group)
        user.is_staff = True
        return super().save_user(user, *args, **kwargs)

以上の実装で https://my-admin.example.com/saml2/login からSAML認証でログインできるようになります。

ログイン画面の拡張

SAML認証でログインできるようにはなったのですが、このままだと既存のログイン画面からの導線がなく、URLを直接入力してログインしてもらう必要があります。 そこで、既存のログイン画面を拡張して「SAMLログイン」ボタンを追加しました。

Django adminのログインページをそのまま流用してテンプレートファイルを作成し、末尾に/saml2/loginに遷移するボタンのHTMLを追加します。

<!-- 
  Django adminのログインページと同じコード:
  https://github.com/django/django/blob/eafe1468d228e6f63d044f787a9ffec82ec22746/django/contrib/admin/templates/admin/login.html 
-->
<!-- (略) -->
</form>

<div class="submit-row">
  <input type="submit" value="SAML {% translate 'Log in' %}" onclick="location.href='/saml2/login'">
</div>

</div>
{% endblock %}

urlpatternsを修正し既存のログイン画面を新しいログイン画面で上書きします。

urlpatterns = [
    path(
        'admin/login/',
        auth_views.LoginView.as_view(
            template_name='login.html',
            extra_context={
                'title': _('Log in'),
                'site_header': admin.site.site_header,
            },
        ),
        name='login',
    ),
    # ...
]

若干無理矢理な実装ですが、既存のパスワードでのログイン方法からは移行しやすくなりました。

まとめ

DjangoSAML対応はなかなか情報がなく調査に苦労したのですが、現状は問題なく稼働しています。 ユーザーの属性ごとにグループや権限を分けるといった自動化はまだできていないのですが、それでもアカウント作成の手間は減らせました。

社内で利用するサービスではまだいくつかAzure ADによるSSOに対応できていない箇所があるので、同様にSSOの対応を進めていきたいところです。

「MoT/コネヒト/Kanmu が語るプロダクト開発xデータ分析」を開催しました

カンムの @fkubota です。

2023/1/26に株式会社Mobility Technologiesさま、コネヒト株式会社さまと合同で「MoT/コネヒト/Kanmu が語るプロダクト開発xデータ分析」というイベントを開催しました。ご参加いただいたみなさま、ありがとうございました!

kanmu.connpass.com

このイベントでは、実プロダクトに機械学習モデルを用いて機能開発・改善を日々行っている3社の機械学習エンジニアが集まり、泥臭く改善を繰り返している現場の苦労や工夫、知見を共有しました。

セッションに登壇しました

弊社からは 僕(fkubota)が「データドリブンな組織の不正検知」というタイトルで登壇しました。

speakerdeck.com

この登壇の一番のメッセージは「あらゆる問題に対して、チームで泥臭く戦っている」というものでした。
タイトルにも記載したとおり、カンムの大半のメンバーがSQLを習得している等データドリブンな組織文化があるため、プロダクトの課題にたいして泥臭く取り組みやすいという点が特徴です。 そのような環境からの後押しを受け、不正利用という緊急に対応したい問題にもそれなりに早い速度で動けているなと、資料を作りながら改めて思いました。
Twitterで参加者のみなさんからの反応を見ても、社員のほとんどがSQLを書けるという部分に反響をいただいており、これは弊社の強みだなと感じています。
この強い文化に甘えず、また泥臭く結果を出していきたいものです。

パネルディスカッションを行いました

パネルディスカッションではMobility Technologiesの @kuto_bopro さん、コネヒトの @asteriam_fp さんを交え、各社の取り組みや知見について語り合いました。 3社それぞれ泥臭い現場の苦難、本やブログにはなかなか載せられない話を聞け、とても刺激的でした。

個人的には以下のような課題に対してどの会社も苦労してるんだなぁと思い、より良くするためにまた集まってディスカッションしたいと思っています。

  • ML評価指標とビジネスの接続
  • オフライン評価
  • ドキュメント管理、ダッシュボード管理(乱立しがち)

▼asteriam_fpさんとkuto_boproさんの登壇資料はこちら

speakerdeck.com

speakerdeck.com

最後に

実はこのイベント、飲み会から始まっています。 以前、データ分析・機械学習を用いてプロダクトの改善に取り組む共通の友人とオンライン飲み会を実施したのですが、これが大いに盛り上がり、その勢いで「オンラインイベントやっちまおうぜ」というノリでこのイベントを実施しました。

あらためましてイベントにご参加いただいたみなさま、またご一緒させていただいたMobility Technologiesさま、コネヒトさま、本当にありがとうございました! 表には出しづらい泥臭い取り組みは聞いてて楽しいし、お互いに刺さる部分が多いので、また似たようなテーマでイベントをやりたいですね。

そして、カンムでは機械学習エンジニアを大募集中です!今回のイベントをきっかけにカンムに少しでも興味を持った方は、ぜひカジュアル面談でお話しましょう。

▼カジュアル面談申込みはこちらから

team.kanmu.co.jp

▼イベントの見逃し配信はこちらから視聴できます!

www.youtube.com

クエリログを使ったPostgreSQLの負荷テスト

SREの菅原です。 この記事はカンム Advent Calendar 2022の4日目の記事になります。

少し前にサービスで使っているPostgreSQLをRDSからAuroraに移行しました。 Auroraに移行するため色々と作業を行ったのですが、その中でAuroraの性能を測るために行った負荷テストについて書きます。

pgbench

まず最初にpgbenchを使って、単純なワークロードでのRDSをAuroraの性能差を測ってみました。*1 以下がその結果です。

MySQLで同様のテストをmysqlslapを使って行ったことがあって、そのときは概ねAuroraのほうが性能が高かったので、同様の結果になると考えていたのですが、RDSのほうが性能が高い結果になったのは予想外でした。

ただAuroraのアーキテクチャを考えると、pgbenchのような細かすぎるトランザクションの場合はRDSのほうが性能が高くなることもありそうなことです。

とりあえず、RDSとAuroraの性能比較にpgbanchは参考にはならなさそうなので、別のやり方を考えることにしました。

qrn

過去にMySQLをAuroraに移行したときにはqrnというツールを作って負荷テストを行いました。 PostgreSQLでも log_statement=all を設定することで postgresql.log にクエリログを出力することができます。 同様のやり方でサービスのワークロードを再現できそうなので、クエリログを使って負荷テストをすることにしました。

クエリログの採取

サービスのクエリログを常時出力すると膨大な量になってしまうので、時間を決めて log_statement=all を設定して、一定時間だけクエリログを有効にします。 また、クエリログを採取する前にRDSのスナップショットを作成し負荷テストの事前データとして利用できるようにしておきます。

このときのログの出力先はCloudWatch LogsではなくRDSのファイルにしています。 RDSにはDownloadCompleteDBLogFileという、いつの間にかドキュメントがなくなってしまったAPIがあって、ログファイルをまるごとダウンロードすることができます。*2

クエリログからテストデータへの変換

postgres.logはndjsonではないので、そのままではqrnのテストデータとして利用することができません。 そこでposlogというツールを作って、postgres.logからndjsonに変換できるようにしました。

$ cat postgresql.log
2022-05-30 04:59:41 UTC:10.0.3.147(57382):postgres@postgres:[12768]:LOG:  statement: select now();
2022-05-30 04:59:46 UTC:10.0.3.147(57382):postgres@postgres:[12768]:LOG:  statement: begin;
2022-05-30 04:59:48 UTC:10.0.3.147(57382):postgres@postgres:[12768]:LOG:  statement: insert into hello values (1);
2022-05-30 04:59:50 UTC:10.0.3.147(57382):postgres@postgres:[12768]:LOG:  statement: commit;
...
$ poslog postgresql.log > data.jsonl
$ cat data.jsonl
{"Timestamp":"2022-05-30 04:59:41 UTC","Host":"10.0.3.147","Port":"57382","User":"postgres","Database":"postgres","Pid":"[12768]","MessageType":"LOG","Duration":"","Statement":" select now();"}
{"Timestamp":"2022-05-30 04:59:46 UTC","Host":"10.0.3.147","Port":"57382","User":"postgres","Database":"postgres","Pid":"[12768]","MessageType":"LOG","Duration":"","Statement":" begin;"}
{"Timestamp":"2022-05-30 04:59:48 UTC","Host":"10.0.3.147","Port":"57382","User":"postgres","Database":"postgres","Pid":"[12768]","MessageType":"LOG","Duration":"","Statement":" insert into hello values (1);"}
{"Timestamp":"2022-05-30 04:59:50 UTC","Host":"10.0.3.147","Port":"57382","User":"postgres","Database":"postgres","Pid":"[12768]","MessageType":"LOG","Duration":"","Statement":" commit;"}
...

ndjsonだとjqでパースできるため、クエリの集計や分析がしやすくなるという副次的なメリットもありました。

トランザクションの再現

pgbenchの結果を踏まえるとワークロードのトランザクションも再現する必要があります。 しかし、qrnでワークロードのトランザクションを完全に再現しようと思うと、クエリログをコネクションIDごとのデータに分解して、それらを別々のエージェントで実行する必要があります。

テスト実施の手間がかなり増えてしまい、何回も試行するのた大変になってきます。そこでqrnに新しく-commit-rateというオプションを追加しました。

github.com

そして、どの程度の割合でcommitが実行されているかをクエリログから調べて、それを-commit-rateオプションに設定して、トランザクションを実際のワークロードに似せることにしました。

負荷テストの実施

負荷テストでは、スナップショットをリストアしたRDSとそこから作成したAuroraスレーブ(promote済み)を準備し、クライアントがボトルネックにならいように十分な性能を持ったEC2インスタンスでqrnを走らせました。

詳細はぼかしていますが、サービスのワークロードではAuroraのほうが性能が高いという結果を得ることができました。

まとめ

PostgreSQLの負荷テストの知見がなかったので、当初はどうしようかと考えていたのですが、MySQLでの経験を上手く活かすことができてよかったです。 その後、Auroraへの移行自体は完了して問題なくサービスで稼働しています。

DBの負荷テストまわりの情報はあまり多くないの気がしているので、今後、同様に知見を公開してくれる人が増えるといいなと考えています。

開発のためにBoltでSlackボットを作った話

SREの菅原です。 この記事はカンム Advent Calendar 2022の2日目の記事になります。

細々としたことをさせるためのボットをSlackに常駐させるのはよく行われていることだと思いますが、カンムにも kanmukun と  kabot という2台のボットが常駐しています。

私の入社当時 kanmukun しかいなかったのですが、あまりメンテナンスされている様子がなく、新しい機能を追加するのが難しかったため、新しく kabot というボットを作成しました。

ボットを作成するにあたって既存のSlackボットフレームワークを利用することを検討しましたが、Hubotはすでにメンテナンスが止まっており、RubotyはカンムにはRubyの文化がないため自分以外が触るのが難しく、となかなかしっくりくるフレームワークは見つからず…

いろいろと調べた結果、最終的にはSlackのソケットモード+Boltでボットを実装することしました。JavaScriptで書き始めましたが、その後同僚のアドバイスを受けてTypeScriptで書き直しました。

kabotの実装

ファイルの構成はこんな感じです。

.
├── Dockerfile
├── Makefile
├── README.md
├── manifest.dev.yml
├── manifest.yml
├── package-lock.json
├── package.json
├── dist/
├── node_modules/
├── src
│   ├── action.ts
│   ├── actions
│   │   ├── asg.ts
│   │   ├── assign.ts
│   │   ├── book.ts
│   │   ├── github-zen.ts
│   │   ├── help.ts
│   │   ├── image.ts
│   │   ├── index.ts
│   │   ├── issue.ts
│   │   ├── omikuji.ts
│   │   ├── oyatsu.ts
│   │   ├── ping.ts
│   │   ...
│   ├── aws.ts
│   ├── github.ts
│   ├── handler.ts
│   ├── index.ts
│   ├── reaction.ts
│   ├── reaction_handler.ts
│   └── reactions
│       ├── index.ts
│       ├── infra_ticket.ts
│       ...
└── tsconfig.json

Handlerクラスがメッセージを受け付けて、マッチするコマンドがあれば関連付けられたアクションを呼び出します。

app.event("app_mention", async (message) => {
  const { say } = message;

  try {
    await Handler.call(message);
    // ...(略)

export class Handler {
  static async call(message: Message) {
    const { event, context, say } = message;
    const text = event.text
      .replace(new RegExp(`^<@${context.botUserId}>\\s+`), "")
      .trim();

    let matched = false;

    for (const [key, action] of this.actions) {
      const matchData = text.match(key);

      if (matchData) {
        matched = true;
        await action.call(matchData, message);
      }
    }
class Ping implements Action {
  name = "ping";
  help = "Return PONG to PING";

  async call(_m: RegExpMatchArray, { body, say }: Message) {
    say({
      text: `<@${body.event.user}> pong`,
      thread_ts: body.event.thread_ts,
    });
  }
}

Rubotyの実装に倣っていて、BrainとAdapterのないHubotのような感じです。

ボットの運用については、ちょうど去年作った内部サービス向けECS環境があったので、そこで動かしています。

kabotの機能

個人的に暇を見つけては、開発で役に立つ機能からどうでも良い機能までちまちまとkabotに追加しています。

Slackユーザーグループからランダム選択

リリース用PRの作成

*1

Issueの作成

PagerDutyのオーバーライド

社員情報の表示

画像検索

Spotify検索

Youtube検索

ニコニコ検索

書籍検索

…等々、他にもいろいろなコマンドを実装しています。

リアクションへの反応

@kabot [コマンド] でkabotにコマンドを実行される以外に、リアクションの絵文字にも反応できるようにしています。

以下はリアクションに反応してIssueを作成している様子です。

実装としては、reaction_addedイベントをフックしてHandlerクラスと同様にマッチしたコマンドを呼び出すようにしています。

app.event("reaction_added", async (message) => {
  if (message.payload.item.type != "message") {
    return;
  }

  try {
    await ReactionHandler.call(message);
    // ...(略)

export class ReactionHandler {
  static async call(message: Message) {
    const { client, payload, event } = message;
    const { channel, ts } = event.item as { channel: string; ts: string };

    for (const [key, reaction] of this.reactions) {
      if (message.event.reaction == key) {
        const ms = await client.conversations
          .replies({
            channel: channel,
            ts: ts,
          })
          .then((h) => h.messages);

        if (ms && ms[0]) {
          await reaction.call(message, ms[0]);
        }

SlackでちょっとつぶやいたことをそのままGitHubのIssueにできるのが便利です。

まとめ

かなりぱぱっと作ったものではあるのですが、pr-releaseコマンドとissueリアクションはそれなりに利用されていて、DXの改善に貢献できていると思います。また、コマンドをサクッと追加できるようにしているので、仕事の気晴らしにくだらないコマンドを追加しするのも楽しいです。

最近はSlackボットにメンションするよりスラッシュコマンドを実行するのほうがメジャーなのかなと思いつつも、@kabot pingと打ってpongと返してくれるのは可愛く思ってしまうので、今後もメンテしていきたいと思っています。

*1:git-pr-releaseを使っています

「泥臭くも価値を届ける決済の仕組みと工夫 by 10X + CAMPFIRE + Kanmu」を開催しました

カンムの achiku です。

2022/11/30に株式会社10Xさま、株式会社CAMPFIREさまと合同で「泥臭くも価値を届ける決済の仕組みと工夫 by 10X + CAMPFIRE + Kanmu」というイベントを開催しました。ご参加いただいたみなさま、ありがとうございました!

kanmu.connpass.com

このイベントでは、普及速度が年々加速している「決済」をテーマに、10X・CAMPFIRE・カンムといった決済の中でも異なる役割のプレイヤーが集まり、泥臭くもユーザーに価値を届ける為に行っている現場感たっぷりのエンジニアリングトークをご紹介しました。

セッションに登壇しました

弊社からは hiroakis さんが「バンドルカードのクレジットカード決済システムの泥臭い運用」というタイトルで登壇しました。

speakerdeck.com

国際ブランド決済ネットワークに参加しているプレイヤーの役割から始まり、仮売上と実売上の突き合わせの妙、ビジネスパートナーの各種環境制約の中でどのようにして効率的なネットワーク環境を構築するかという、まさに現場感たっぷりなお話になっております。自分はこの佐野さんの仕事をずっと間近で見ているのですが、一つ一つ問題を定義し構造全体の整合性を取りながら、カスタマーサポートやビジネスパートナーと粘り強く議論しながら事業を前に進めていく馬力は本当に尊敬しています。

パネルディスカッションを行いました

パネルディスカッションでは10Xの @yamarkz さん、CAMPFIREの@t_aogawa さん、カンムの @hiroakis さんを交え、自分(=achiku)がモデレーターを務めながら、各社の取り組みや知見について語り合いました。

モデレーターとして話は弾むかな...と若干ドキドキしていた部分もあるのですが、そんな心配全く不要なほどお三方で活発な議論がなされており、やはり自分たちの向こう側にある仕組みがどうなっているか、どういう工夫があるのか、という話題は面白いなぁと思いながら聞いておりました。

自分たちはイシュアー(カード発行事業者)としてユーザーさんに価値を届けようとしているのですが、その先にはもちろん10XさんやCAMPFIREさんのように加盟店としてユーザーさんに価値を届けようとしている方たちがおり、当たり前なんですがその中でなされている工夫がたくさんあり、いつもとは異なる観点から「決済」というものに向き合えてとても刺激的でした。なんか自分たちの向こう側にもユーザーに価値を届けようと頑張ってる人たちがいるって良いですよね。励まされるといいますか。

最後に

イベント中も何度か言ったと思うのですが、過去無いほどインターネット上で価値が交換されていて、その交換のフォーマットの多様性は増しています。10Xさんのように20点もの商品を組み合わせてインターネット上で生鮮食品/日用品を購入するという行いも、CAMPFIREさんのようにプロジェクトの成否によって決済が実行されるがそれまでは仮売上で保留するという形も、もしかしたらネット上のサブスクや、Pay Per Viewや、投げ銭や、一話購読だって、インターネットと決済の組み合わせで発生し始めた新しい価値交換のフォーマットだと考えています。そんな領域に対するソフトウェア的な工夫や、経験談はきっと、新しい領域をソフトウェアに落とし込む際の良い参考になるんじゃないかなぁと考えております。そんなわけでこういうイベントはまたやりたいです!

あらためましてイベントにご参加いただいたみなさま、またご一緒させていただいた10Xさま、CAMPFIREさま、本当にありがとうございました!

そして、カンムではエンジニアを大募集中です!今回のイベントをきっかけにカンムに少しでも興味を持った方は、ぜひカジュアル面談でお話しましょう。

▼カジュアル面談申込みはこちらから

team.kanmu.co.jp

イベントの見逃し配信はこちらから視聴できます!

www.youtube.com

「価値を最大化して素早くユーザーへ届けるための開発フロー〜Figma編〜 SmartHRxKanmu」を開催しました

こんにちは!カンムの小山内です。

2022/11/21に株式会社SmartHRさまと合同で「価値を最大化して素早くユーザーへ届けるための開発フロー〜Figma編〜 SmartHRxKanmu」というイベントを開催しました。なんと100名以上の方にお申し込みいただけたようです。ご参加いただいたみなさま、ありがとうございました!

kanmu.connpass.com

このイベントでは、日々のプロダクト開発におけるFigmaを活用したスムーズな意思決定、ベストプラクティスの蓄積、開発コラボレーションの実践的なノウハウなどを、SmartHR・カンム各社の実体験をベースにご紹介しました。 またイベント後半のパネルディスカッションは、登壇者だけでなく参加者のみなさまにもインタラクティブに発言できるよう、Twitter Spaceにて実施しました。

セッションに登壇しました

speakerdeck.com

このセッションではプロダクトの価値を高めるために日常的にやっていることや、Figmaを活用しながら素早く開発できるよう改善したことなどを紹介しました。 デザイナーとエンジニアが快適・快速に仕事ができるよう、小さな工夫も地道に積み重ねているところです。

パネルディスカッションを行いました

パネルディスカッションではSmartHR デザイナーの 大河原さん を交え、モデレーターの @_achiku と各社の取り組みや知見について語り合いました。

  • モブデザインの勘所
  • 目指しているデザインシステムの形
    • デザインとコードを1対1にすることのメリットと課題
    • プロダクトを横断した管理方法

最後に

Figmaの活用方法は開発チームによって様々な形があります。カンムも工夫と試行錯誤をしながら日々開発を進めていますが、今回SmartHRさまの活用方法について詳しく聞けたことや意見交換したことはとても学びになりました。イベントにご参加いただいたみなさま、またご一緒させていただいたSmartHRの大河原さん、本当にありがとうございました!

そして、カンムではエンジニアを大募集中です!今回のイベントをきっかけにカンムに少しでも興味を持った方は、ぜひカジュアル面談でお話しましょう。

▼カジュアル面談申込みはこちらから

team.kanmu.co.jp