Slack チャットボット的なものを作るときは / command よりも @chatbot で話しかけるのを俺は好む

追記

教えていただきました! ありがとうございます!

Enabling interactivity with Slash Commands | Slack

When the response_type is in_channel, both the response message and the initial Slash Command typed by the user will be shared in the channel:


チャットボットというか最近だと Slack アプリを作ると大体 /chatbot do something 的な実装されることが多いし実際推奨されている気がする。ちなみに別に裏はとってないけどw で、@chatbot do something 的な話しかけるやり方はモダンじゃない古いやり方な気もする。

だけど俺は @chatbot do something 的なやつの方が好きだ。というか /command 的なのはチャットボットの良さをひとつ消している気がする。俺の勝手定義でチャットボットの二大利点は

  1. スグにアクセスできる場所でコマンド実行できる
  2. 何を実行したか他の人にも見えている

だと思っている。で、2の利点が俺は結構大事だと思ってて、これは通知とは違って他の人に誰が何をやったかが見えるし、新しく入った人も自然に覚える。

例えばデプロイを例に出してみよう

/command の場合

  1. デプロイする人は Slack に /chatbot deploy と打ち込む。これは打ち込んだ本人にしか見えない。
  2. chatbot が応答で「デプロイします」とかいう。これは全員に見える

@chatbot で話しかける場合

  1. デプロイする人は Slack に @chatbot deploy と打ち込む。これは全員に見える
  2. chatbot が応答で「デプロイします」とかいう。これは全員に見える

@ で話しかけた方は誰が話しかけたのかも、なんて話しかけたのかも全員に見える。新しく入社したエンジニアもそれをみたら「あ、俺もああやって話しかければいいんだな」とスグに理解できるだろう。でも /command の時はボットの反応しか見えない。ボットの反応テキストを工夫すれば誰が行ったかとかは見える。けれどもどういったコマンドを打ったかは見えない。なんならコマンドラインか何かでデプロイ実行した時の通知が飛んできているのとの判断も難しい。それを見ただけでは /command で実行したのか、他の手段で実行して通知が来ただけなのか判断するのは難しいだろう。

もちろん /command にも保管とか色々なUIを入力者に出せたり利点があるのは理解してる。でも俺はそういったのが必要でなければなるべく @ で話しかける方で実装する方が好きかなー

本題ですが最近のおすすめの漫画はカナカナです。

graphql-java で雑に pagination 実装できるようにした

yoshiori.hatenablog.com

の続きです。 GraphQL の pagination っていうか Connections の仕様はGraphQL Cursor Connections Specification で、まぁ、実装した。基本的にはDBへクエリ投げやすくするように first/afterlast/beforeoffset/limit に変換して渡すようにしてます。

コメントにも書いたけど変換はこんな感じになる。一応エッジケース全部テストしたはず。

   * for Example
   * [0,1,2,3,4]
   *
   * ## first/after
   * first:2, after:1 -> [2,3] -> offset:2, limit:2,
   * first:3, after:0 -> [1,2,3] -> offset:1, limit:3
   * first:null, after:2 -> [3,4] -> offset:3, limit:DEFAULT_SIZE(20)
   *
   * ## last/before
   * last:2, before:3 -> [1,2] -> offset: 1, limit:2
   * last: 2, before: null -> [3,4] -> offset: 3, limit: 2
   * last: null, before: 3 -> [0,1,2] -> offset: 0, limit: 3
   * last 10, before: 2 -> [0,1] -> offset: 0, limit: 2

DataFetcher Interface を実装してるので多分各種WebFrameworkでも使えるはず(俺は DGS 使ってるのでそれだけ動作確認済み)

使い方はトータル件数と offset/limit 引数に処理する関数を渡すだけ

new OffsetLimitConnection<>(fooService.getTotal(), fooService::getFoos).get(env));

標準でこのくらい用意してあるんじゃないの?とか誰か参考実装とか公開してるんじゃないの??とか思ったけど見つけられなかったので同じような人がいたら助かるように貼っておきます。

import static graphql.Assert.assertNotNull;
import static java.nio.charset.StandardCharsets.UTF_8;

import com.google.common.base.Objects;
import com.google.common.io.BaseEncoding;
import graphql.relay.Connection;
import graphql.relay.ConnectionCursor;
import graphql.relay.DefaultConnection;
import graphql.relay.DefaultEdge;
import graphql.relay.DefaultPageInfo;
import graphql.relay.Edge;
import graphql.schema.DataFetcher;
import graphql.schema.DataFetchingEnvironment;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.function.BiFunction;

public class OffsetLimitConnection<T> implements DataFetcher<Connection<T>> {

  static final int DEFAULT_SIZE = 20;
  final int total;
  final BiFunction<Integer, Integer, List<T>> func;

  public OffsetLimitConnection(Integer total, BiFunction<Integer, Integer, List<T>> func) {
    this.total = assertNotNull(total, () -> "total cannot be null");
    this.func = assertNotNull(func, () -> "function cannot be null");
  }

  /**
   * Convert to offset/limit from last/before or last/before.
   *
   * <pre>
   * for Example
   * [0,1,2,3,4]
   *
   * ## first/after
   * first:2, after:1 -> [2,3] -> offset:2, limit:2,
   * first:3, after:0 -> [1,2,3] -> offset:1, limit:3
   * first:null, after:2 -> [3,4] -> offset:3, limit:DEFAULT_SIZE(20)
   *
   * ## last/before
   * last:2, before:3 -> [1,2] -> offset: 1, limit:2
   * last: 2, before: null -> [3,4] -> offset: 3, limit: 2
   * last: null, before: 3 -> [0,1,2] -> offset: 0, limit: 3
   * last 10, before: 2 -> [0,1] -> offset: 0, limit: 2
   * </pre>
   *
   * @param total
   * @param first
   * @param after
   * @param last
   * @param before
   * @return offsetAndLimit
   */
  static OffsetAndLimit getOffsetAndLimit(
      int total, Integer first, Integer after, Integer last, Integer before) {
    if ((first == null && after == null && last == null && before == null)
        || ((first != null || after != null) && (last != null || before != null))) {
      throw new IllegalArgumentException(
          "Only the combination of first/after or last/before can be given.");
    }
    int offset;
    int limit;

    // first/after
    if (first != null || after != null) {
      offset = after == null ? 0 : after + 1;
      limit = first == null ? DEFAULT_SIZE : first;

      // last/before
    } else {
      if (before == null) {
        before = total;
      }

      if (last == null) {
        last = DEFAULT_SIZE;
      }

      offset = Math.max(0, before - last);
      limit = Math.min(before, last);
    }
    return new OffsetAndLimit(offset, limit);
  }

  @Override
  public Connection<T> get(DataFetchingEnvironment environment) {
    Integer first = environment.getArgument("first");
    Integer after = NumberCursor.convertToOffset(environment.getArgument("after"));
    Integer last = environment.getArgument("last");
    Integer before = NumberCursor.convertToOffset(environment.getArgument("before"));

    var offsetAndLimit = getOffsetAndLimit(total, first, after, last, before);
    var offsetCursor = offsetAndLimit.offset;
    var limitCursor = offsetAndLimit.limit;

    List<T> list = func.apply(offsetCursor.toInt(), limitCursor.toInt());
    if (list.isEmpty()) {
      return emptyConnection();
    }

    List<Edge<T>> edges = new ArrayList<>();
    int index = offsetCursor.toInt();
    for (T o : list) {
      edges.add(new DefaultEdge<>(o, new NumberCursor(index++)));
    }

    var firstEdge = edges.get(0);
    var lastEdge = edges.get(edges.size() - 1);
    var pageInfo =
        new DefaultPageInfo(
            firstEdge.getCursor(),
            lastEdge.getCursor(),
            0 < ((NumberCursor) firstEdge.getCursor()).toInt(),
            ((NumberCursor) lastEdge.getCursor()).toInt() < total - 1);
    return new DefaultConnection<>(edges, pageInfo);
  }

  Connection<T> emptyConnection() {
    return new DefaultConnection<>(
        Collections.emptyList(), new DefaultPageInfo(null, null, false, false));
  }

  static class OffsetAndLimit {
    final NumberCursor offset;
    final NumberCursor limit;

    OffsetAndLimit(int offset, int limit) {
      this.offset = new NumberCursor(offset);
      this.limit = new NumberCursor(limit);
    }
  }

  static class NumberCursor implements ConnectionCursor {
    static final String PREFIX = "DUMMY";
    final int num;

    NumberCursor(int num) {
      this.num = num;
    }

    static Integer convertToOffset(String cursorString) {
      if (cursorString == null) {
        return null;
      }
      try {
        var string = new String(BaseEncoding.base64().decode(cursorString), UTF_8);
        return Integer.parseInt(string.substring(PREFIX.length()));
      } catch (IllegalArgumentException ignored) {
      }
      return null;
    }

    /** @return an opaque string that represents this cursor. */
    @Override
    public String getValue() {
      return convertToCursorString(num);
    }

    private String convertToCursorString(int num) {
      return BaseEncoding.base64().encode((PREFIX + num).getBytes(UTF_8));
    }

    @Override
    public boolean equals(Object o) {
      if (this == o) {
        return true;
      }
      if (o == null || getClass() != o.getClass()) {
        return false;
      }
      NumberCursor that = (NumberCursor) o;
      return num == that.num;
    }

    @Override
    public int hashCode() {
      return Objects.hashCode(num);
    }

    public int toInt() {
      return num;
    }
    /**
     * In fact, the getValue method is not called, because toString has been called.
     *
     * @see <a href="https://github.com/graphql-java/graphql-java/issues/1944">GraphQL-java uses
     *     toString instead of ConnectionCursor getValue</a>
     * @return getValue() string
     */
    @Override
    public String toString() {
      return getValue();
    }
  }
}

graphql-java で 雑に pagination 実装したい

実装した graphql-java で雑に pagination 実装できるようにした - 宇宙行きたい


なんかサクッと返してくれるのないかなぁと探したんだけどなかった。 SimpleListConnection は全部のリスト渡さないといけないし。。。ということで雑に書いたんだけど、これをそのまま使いたいってよりもこういうことしたいんだけど自分で実装しないでもなんか汎用的なのあるんでしょ!?俺が見つけられてないだけで!!! 教えてください!!!的な気持ちで書いてみた。(出てこなかったらこれもうちょっと弄って使うけど)

    var count = hogeMapper.count();
    var hogeConnection = new SimpleConnection<Hoge>(count, (offset, limit) -> {
      return hogeMapper.getHoges(offset, limit);
    }).get(env);

こんな感じでページネーションしたいリストの合計サイズと offset, limit を元に list 返す関数渡すと graphql.relay.Connectionインスタンス返してくれるやつ

import static graphql.Assert.assertNotNull;

import com.google.common.base.Objects;
import com.google.common.io.BaseEncoding;
import graphql.relay.Connection;
import graphql.relay.ConnectionCursor;
import graphql.relay.DefaultConnection;
import graphql.relay.DefaultEdge;
import graphql.relay.DefaultPageInfo;
import graphql.relay.Edge;
import graphql.schema.DataFetchingEnvironment;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.function.BiFunction;

public class SimpleConnection<T> {

  final SimpleConnectionCursor sizeCursor;
  final BiFunction<Integer, Integer, List<T> > func;

  public SimpleConnection(Integer size, BiFunction<Integer, Integer, List<T> > func) {
    this.sizeCursor = new SimpleConnectionCursor(assertNotNull(size));
    this.func = assertNotNull(func);
  }

  public Connection<T> get(DataFetchingEnvironment environment){
    Integer firstInteger = environment.getArgument("first");
    if(firstInteger == null){
      firstInteger = 20;
    }
    var limitCursor = new SimpleConnectionCursor(firstInteger);
    var offsetCursor = new SimpleConnectionCursor(
      (String) environment.getArgument("after"));
    if(offsetCursor.getOffset() != 0){
      offsetCursor = new SimpleConnectionCursor(offsetCursor.getOffset() + 1);
    }

    List<T> list = func.apply(offsetCursor.getOffset(), limitCursor.getOffset());
    if(list.isEmpty()){
      return emptyConnection();
    }

    List<Edge<T>> edges = new ArrayList<>();
    int index = offsetCursor.getOffset();
    for(T o : list){
      edges.add(new DefaultEdge<>(o, new SimpleConnectionCursor(index++)));
    }

    var firstEdge = edges.get(0);
    var lastEdge = edges.get(edges.size() - 1);
    var pageInfo = new DefaultPageInfo(
      firstEdge.getCursor(),
      lastEdge.getCursor(),
      0 < ((SimpleConnectionCursor)firstEdge.getCursor()).getOffset(),
      ((SimpleConnectionCursor)lastEdge.getCursor()).getOffset() < sizeCursor.getOffset() -1
    );
    return new DefaultConnection<>(
      edges,
      pageInfo
    );
  }

  Connection<T> emptyConnection() {
    return new DefaultConnection<>(Collections.emptyList(), new DefaultPageInfo(null, null, false, false));
  }

  static class SimpleConnectionCursor implements ConnectionCursor{
    static final String PREFIX = "DUMMY";
    final int offset;

    SimpleConnectionCursor(int offset) {
      this.offset = offset;
    }

    SimpleConnectionCursor(String cursor) {
      this.offset = convertToOffset(cursor);
    }

    /**
     * @return an opaque string that represents this cursor.
     */
    @Override
    public String getValue() {
      return convertToCursorString(offset);
    }

    public int getOffset(){
      return offset;
    }

    private int convertToOffset(String cursorString) {
      if (cursorString == null) {
        return 0;
      }
      try{
        var string = new String(BaseEncoding.base64().decode(cursorString), StandardCharsets.UTF_8);
        return Integer.parseInt(string.substring(PREFIX.length()));
      }catch (IllegalArgumentException ignored){
      }
      return 0;
    }

    private String convertToCursorString(int offset) {
      return BaseEncoding.base64().encode((PREFIX + Integer.toString(offset)).getBytes(StandardCharsets.UTF_8));
    }

    @Override
    public boolean equals(Object o) {
      if (this == o) {
        return true;
      }
      if (o == null || getClass() != o.getClass()) {
        return false;
      }
      SimpleConnectionCursor that = (SimpleConnectionCursor) o;
      return offset == that.offset;
    }

    @Override
    public int hashCode() {
      return Objects.hashCode(offset);
    }
  }
}

ドラム式乾燥機に2日放置しててもシワにならないシャツ

最近服を買う時に一番大事にしているポイントは乾燥機に放置してもシワにならないことです。次点で猫の毛が目立たない。

と言うことで今年買ってマジでシワにならなくて感動したのがGUのこのシャツ。マジでシワにならなすぎてちょっと縫い目がチクチク感じることもあるけど慣れればOK。生地感は何というか普通のシャツとかと違って未来素材感ある。テカテカツルツルな感じ、でもシワにならないの幸せ。今年の夏はこのシャツの白と黒を2枚づつの計4枚を回して着てほぼ過ごしました。来年もあったら必ず書います。

www.gu-global.com

 

ちなみに次点ではユニクロエアリズムコットンオーバーサイズTシャツ(5分袖)。これはシワになりにくいけど放置するとやっぱりちょっとシワになる。けど生地感は凄く普通のTシャツっぽくなるので何となく少し生地感気にするようなお出かけの時はあり

「ZZ 観なくていいと思うんですけど」という声を聞いて

僕 ZZ 大好きなんですよ。あとGガンも大好きです。共通する部分として導入の何話かの空気感が辛くて辛くて観るのを辞めちゃう人が多いっていうのがありますね。

でもプルとプルツーの戦いのセリフとかマシュマーの散り際のカッコ良さとかハマーンの最後の諦めにも似た敗北とか色々見所あるので観て欲しい気持ちはあります。

あと、プルが可愛いです。小学生の時にアニメのキャラに恋をするといういきなり道を外してしまうことになったのはこのキャラのせいですね。(次がナディア)

追加

それぞれのシーンリンクしておきますね。ネタバレ大丈夫な人は是非

Netflix DGS Framework で Spring の Controller みたいに入力値のバリデーションする

こんな感じで AOP で処理してDataFetcherExceptionHandlerで良しなに処理してる。

package org.yoshiori.datafetchers.aop;

import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.core.MethodParameter;
import org.springframework.stereotype.Component;
import org.springframework.validation.DataBinder;
import org.springframework.validation.beanvalidation.SpringValidatorAdapter;
import org.springframework.web.bind.MethodArgumentNotValidException;

import javax.validation.Validator;

@Aspect
@Component
public class GraphQLValidator {

    final Validator validator;

    public GraphQLValidator(Validator validator) {
        this.validator = validator;
    }

    /**
     * Validation like the Spring controller.
     *
     * @param jp
     */
    @Before("execution(* org.yoshiori.datafetchers..*(..)) && @within(com.netflix.graphql.dgs.DgsComponent)")
    public void validateParameters(JoinPoint jp) {
        var signature = jp.getSignature();
        if (!(signature instanceof MethodSignature)) {
            return;
        }
        var methodSignature = (MethodSignature) signature;
        var parameters = jp.getArgs();
        var annotations = methodSignature.getMethod().getParameterAnnotations();
        SpringValidatorAdapter adapter = new SpringValidatorAdapter(this.validator);

        for (var i = 0; i < annotations.length; i++) {
            for (var annotation : annotations[i]) {
                if (annotation.annotationType().equals(javax.validation.Valid.class)) {
                    var bindingResult = new DataBinder(parameters[i]).getBindingResult();
                    adapter.validate(parameters[i], bindingResult);
                    if (bindingResult.hasErrors()) {
                        var m = MethodParameter.forParameter(methodSignature.getMethod().getParameters()[i]);
                        throw new RuntimeException(new MethodArgumentNotValidException(m,
                            bindingResult));
                    }
                }
            }
        }
    }
}

使い方

@DgsData(parentType = "Query", field = "shows")
public List<Show> shows(@InputArgument @Valid Hoge hoe){
  ...
}

なんかもっと良い方法あったら教えて欲しい。

🇺🇸 スタートアップで経験したシードからシリーズAの資金調達まで

シリーズAの資金調達

japan.cnet.com

シリーズAの資金調達をしました。USのスタートアップでシードラウンドから経験するのって結構貴重な経験かなって思ったので資金調達までどんな感じだったのか描いてみようと思います。

そもそもシードとかシリーズAとか何?

ググってもらった方が正確な情報が出てくると思うので詳しく知りたい人はググってください。 すっごい雑に言うとこんな感じです。

  • シードラウンド
    • アイディア段階での調達、そのアイディア良さそうじゃんって思ってもらったら投資してもらう感じ
  • シリーズA
    • ある程度動くものができて顧客も見込めそうじゃんって思ってもらって投資してもらう感じ

僕が入社した段階

で、シードラウンドの途中で僕は入社しました。1年3ヶ月くらい前。

yoshiori.hatenablog.com

その時のプロダクトがどんな状況だったのかを説明すると、まだコアロジックのプロトタイプが出来ていたくらい。
ソースコードとかコミットログとか過去のテスト結果とか全部手元に有れば性能の出るモデルが作れることはわかったくらい。
そこからどうやってデータを集めるのか、そのモデルをどうやって実行してもらってどうやって結果を動かしてもらうのかとかから考え始めました。
簡単に言えばデータあれば性能出ることはわかったけどじゃぁそのデータってどうやって集めるの?と。
APIサーバ作ったり仕様考えたりもそうだし、データをどうやって持つのとか貯めるにしてもSaaSになるはずなのでデータ間のセキュリティも担保しなきゃいけないしとか、認証どうするとかそもそもインクリメントにデータ貯めて学習どうするんだとか
そこまで出来てWebinarとかやって「興味ある人はメールしてね」みたいな感じで募集をかけてそっからPMがSQL叩いてAPIトークン発行してメールで送るとか
それをカスタマーが自分でサインアップして自分でトークン発行したり出来るようにしたり、サイトから付加情報見れるようにしたり

正解は誰も知らないので何に注力するのか決めながら進んでいく感じでした。例えばDBは最後の砦だか最初から堅牢にセキュアになるように設計したり、逆にAPIサーバはデプロイ前のチェックを重厚にするよりエラー早期発見出来るようにしてすぐロールバック出来るようにしたり色々考えながら作って、作って運用して考えて変更してという感じで進んでました。っていうか今もそんな感じで進んでいます。

僕が感じたこと

とりあえず楽しい。やらなきゃいけない事はいくらでもあるのでその中から自分で取捨選択して方針決めていくのはやっぱり楽しい。何やっても基本的に0 -> 1になるので何かするたびに出来ることが増えるのは純粋にやっぱり面白い。今はやっと最低限の道具は揃った感じにはなった。でも勿論全部完璧なわけなくてガムテープでとりあえず補修してる的な部分もある。なので新しいカスタマーがデータを送ってきてくれるようになると想定してなかった事とか起きて水道管ゲームのあっちの水漏れ直したらこっちで水漏れしてみたいな感じで日々色々な問題に対応したりしている。

焦燥感は結構あった。シードラウンドは本当に調達した資金を食い潰しながら走っている状況でこれを経験するのは本当に初めてなので焦燥感は結構あった。昔所属してた会社で月数億単位で赤字とかは経験したことあるけど赤字でも売り上げがあるのとは全然違う感覚だった。赤字でも100円でも売り上げがあればあとはそれを拡大していけばどんどん収入増えるだろうけど、そもそも売り上げが何もないのでゼロをいくら掛けてもゼロにしかならない状態、なので早く売り上げを作れる状況まで持っていかなきゃいけないっていう焦りはずっとあった。まぁ、資金食い潰してるとか偉そうに言ってるけど別にどのくらい残ってるのかとか見てたわけではないので本当に個人的に感じてただけなんだけど。

あとは前にも描いたけど、英語はやっぱり苦労しているとか、同僚がみんな優秀で楽しいとかもあります。

そんなこんなで本題

そんな感じでシリーズAの資金調達をしたんだけどむしろここからが本当の勝負どころ!!!
で大変だけど楽しいこともいっぱいあると思ってます。そして人手も全然足りていない!!!
特に今はData Scientistが欲しいです!僕らのサービスのコアである機械学習部分の改善をしていける人を探しています!一緒に小さいベンチャーから世界にインパクトを与える仕事しませんか?楽しいのは保証します!!!

www.launchableinc.com