JavaでSlack botをリリースするまで その１

# はじめに
SLackのアプリをJavaで実装する方法に、Slackが提供するフレームワークboltがあります。
今回はこちらを使用して既存のアプリをSlackに対応させる方法・手順について記載します。

# アプリの作成

## Slack側の設定

### 新規アプリの登録
[Create New App](https://api.slack.com/apps) より新規アプリを登録します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/20464/15383a51-ae00-d602-94b1-6c2368809582.png)

### OAuth & Permissions の設定
Permission を設定します。

設定は一例ですので必要に応じて設定を変更してください。
今回は Slash Commands を利用するので、以下のようにしています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/20464/18075c81-6ecf-e6cb-bb7e-902e1b7507b9.png)

Workspace にアプリをインストールしてください。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/20464/5a6417bd-efb8-52c7-b75a-c256215fbf72.png)

Tokenが発行されます。
![FireShot Capture 093 - Slack API_ Applications - livlog Slack - api.slack.com.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/20464/37bccd57-8cbc-4325-8e63-d4a605a63294.png)

Redirect URLs の設定はアプリごとにOauth認証の実装が個別に必要になります。
[OAuthを使用したインストール](https://api.slack.com/authentication/oauth-v2)に詳細が書かれていますが、こちらの記事では割愛しています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/20464/876c1f50-9e1a-2be8-55e5-b5ff2481bf3c.png)

### Slash Commandsの登録
今回実装するSlash Commandsを登録していきます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/20464/fe5385e3-b583-a655-1d21-c4d3e47e5fb3.png)

Postで呼び出すサーバ側のURLを登録します。
![68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f32303436342f32356462393163332d386565302d636638622d613862612d3661383534313635393734342e706e67.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/20464/d08c3f7e-eec1-a64d-ad47-965c4fd2d33f.png)


## Java側の設定
Postの処理を実装するために、、ここではSpring bootを使用します。
githubの[サンプル](https://github.com/slackapi/java-slack-sdk/tree/main/bolt-spring-boot-examples)を参考に実装していきます。

Mavenのpom.xmlに以下を追加します。

```xml:pom.xml
<dependency>
    <groupId>com.slack.api</groupId>
    <artifactId>bolt</artifactId>
    <version>1.22.1</version>
</dependency>
<dependency>
    <groupId>com.slack.api</groupId>
    <artifactId>bolt-servlet</artifactId>
    <version>1.22.1</version>
</dependency>
```

@ServletComponentScan のアノテーションをつけているので、Spring bootにServletを実装する形になります。

```java:Application.java
@SpringBootApplication
@ServletComponentScan
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}
```

ここら辺から少しサンプルを改造しています。
Tomcatなどから動かす場合は、コンストラクタの書き方でエラーが発生していたので、このような形に変更しています。

```java:SlackEventsServlet.java
@WebServlet ("/slack/events")
public class SlackEventsServlet extends AbsSlackAppServlet {

    private final AppService appService = this.injector.getInstance(AppServiceImpl.class);

    public SlackEventsServlet() {

        super(new SlackApp().initSlackApp(new SlackApp().loadAppConfig()));
    }


    public SlackEventsServlet(App app) {

        super(app);
    }


    @Override
    protected void performTask(HttpServletRequest req, HttpServletResponse res) throws Exception {

        final Request <?> slackReq = this.getAdapter().buildSlackRequest(req);
        if (slackReq != null) {
            final var queryString = slackReq.getRequestBodyAsString();
            final var queryPairs = this.splitQuery(queryString);
            // System.out.println(queryPairs);
            final var appList = this.appService.getTeam(AppType.SLACK_NEW.cd, queryPairs.get("team_id"));
            if (!appList.isEmpty()) {
                final var appDto = appList.get(0);
                this.getApp().config().setSingleTeamBotToken(appDto.getToken());
                final var slackResp = this.getApp().run(slackReq);
                this.getAdapter().writeResponse(res, slackResp);
            }
        }
    }
}
```

SlackApp では、Slash Commands の処理を書くのですが、3秒ルールがあるようで、3秒以内でレスポンスを返す必要があります。
そのため、Thread を利用しており、Slackからのレスポンスの中に response_url にPostを返信します。

※参考:[Slackのスラッシュコマンドのタイムアウトエラー解消法](https://blog.serverworks.co.jp/2020/11/30/135252)

```java:SlackApp.java
@Slf4j
public class SlackApp {

    @Bean
    public AppConfig loadAppConfig() {

        final var config = new AppConfig();
        final var classLoader = SlackApp.class.getClassLoader();
        // src/test/resources/appConfig.json
        try (var is = classLoader.getResourceAsStream("slack.json");
                var isr = new InputStreamReader(is)) {
            final var json = new BufferedReader(isr).lines().collect(Collectors.joining());
            // JsonObject j = new Gson().fromJson(json, JsonElement.class).getAsJsonObject();
            final var j = (Map <?, ?>) JSON.decode(json);
            config.setSigningSecret(j.get("signingSecret").toString());
            // config.setSingleTeamBotToken(j.get("singleTeamBotToken").toString());
        } catch (final IOException e) {
            SlackApp.log.error(e.getMessage(), e);
        }
        return config;
    }


    @Bean
    public App initSlackApp(AppConfig config) {

        final var app = new App(config);

        app.command("/talk", (req, ctx) -> {
            final var userId = ctx.getRequestUserId();
            final var text = req.getPayload().getText();
            final var url = req.getPayload().getResponseUrl();

            SlackQueue r = new SlackQueue();
            r.setUserId(userId);
            r.setText(text);
            r.setUrl(url);

            Thread t = new Thread(r);
            t.start();

            return ctx.ack();
        });

        return app;
    }

}

@Data
@Slf4j
class SlackQueue implements Runnable {

    /** TransactionManager. */
    protected final TransactionManager tm         = jp.livlog.cotogoto.AppConfig.singleton().getTransactionManager();

    /** Injector. */
    private final Injector             injector   = Guice.createInjector();

    /** BotService. */
    private final BotService           botService = this.injector.getInstance(BotServiceImpl.class);

    private String                     userId;

    private String                     text;

    private String                     url;

    @Override
    public void run() {

        this.tm.required(() -> {
            try {
                final var repry = this.botService.nobySlackMessenger(userId, text);
                // System.out.println(repry);
                final Map <String, String> params = new LinkedHashMap <>();
                params.put("text", repry);
                final Map <String, String> httpHeaders = new LinkedHashMap <>();
                doPost(url, Symbol.UTF_8, httpHeaders, JSON.encode(params));

            } catch (final Exception e) {
                try {
                    throw new CotogotoException(e);
                } catch (final CotogotoException e1) {
                    log.error("Failed to handle a request - {}", e.getMessage(), e);
                }
            }
        });
    }

    public String doPost(String url, String encoding, Map <String, String> headers, String jsonString) throws IOException {

        final okhttp3.MediaType mediaTypeJson = okhttp3.MediaType.parse("application/json; charset=" + encoding);

        final RequestBody requestBody = RequestBody.create(mediaTypeJson, jsonString);

        final Request request = new Request.Builder()
                .url(url)
                .headers(Headers.of(headers))
                .post(requestBody)
                .build();

        final OkHttpClient client = new OkHttpClient.Builder()
                .build();
        final Response response = client.newCall(request).execute();
        final String resultStr = response.body().string();
        return resultStr;
    }
}
```

AbsSlackAppServlet は SlackAppServletというクラスがMavenのbolt-servletにあったのですが、実際にアプリのリリースをするためには、ワークスペース単位では管理できない作りになっていたので、独自に作成しています。

```java:AbsSlackAppServlet.java
@Slf4j
@Data
@EqualsAndHashCode (callSuper = false)
public abstract class AbsSlackAppServlet extends HttpServlet {

    /** App. */
    private final App                    app;

    /** SlackAppServletAdapter. */
    private final SlackAppServletAdapter adapter;

    /** TransactionManager. */
    protected final TransactionManager   tm       = AppConfig.singleton().getTransactionManager();

    /** Injector. */
    protected final Injector             injector = Guice.createInjector();

    public AbsSlackAppServlet(App app) {

        this.app = app;
        this.adapter = new SlackAppServletAdapter(app.config());
    }


    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse res) throws IOException, ServletException {

        try {
            this.tm.required(() -> {
                try {
                    this.performTask(req, res);
                } catch (final Exception e) {
                    throw new RuntimeException(e);
                }
            });
        } catch (final Exception e) {
            try {
                throw new CotogotoException(e);
            } catch (final CotogotoException e1) {
                AbsSlackAppServlet.log.error("Failed to handle a request - {}", e.getMessage(), e);
                res.setStatus(500);
                res.setContentType("application/json");
                res.getWriter().write("{\"error\":\"Something is wrong\"}");
            }
        }
    }


    protected Map <String, String> splitQuery(String query) throws UnsupportedEncodingException {

        final Map <String, String> queryPairs = new LinkedHashMap <>();
        final var pairs = query.split("&");
        for (final String pair : pairs) {
            final var idx = pair.indexOf("=");
            queryPairs.put(URLDecoder.decode(pair.substring(0, idx), "UTF-8"), URLDecoder.decode(pair.substring(idx + 1), "UTF-8"));
        }
        return queryPairs;
    }

    protected abstract void performTask(HttpServletRequest req, HttpServletResponse res) throws Exception;
}
```

# 続く
今回はこのように Slash Commands の送受信部分を作成しましたが、次回ではアプリの提出以降の手順は[その２](https://qiita.com/blue_islands/items/a920a35d6153817924dc)に続けていきます。


[第1回](https://qiita.com/blue_islands/items/bd16d23bb75e818cd133)
[第2回](https://qiita.com/blue_islands/items/a920a35d6153817924dc)
[第3回](https://qiita.com/blue_islands/items/0b1299035bd86277c8ba)