<repo_name>yuma140902/line-echo<file_sep>/src/word-verifier.ts
import kuromoji from 'kuromoji'
import * as kana_util from './kana-util'

// ===== 形態素解析に関するモジュール =====

type ErrorReason = 'NOT_A_WORD' | 'COMPOUND_NOUN' | 'UNKNOWN_WORD';

type VerifyWordResult = Success | Fail;

type Success = {
  succeeded: true,
  tokens: kuromoji.IpadicFeatures[],
  surface: string,
  kana: string
}

type Fail = FailBase | FailNotANoun;

type FailBase = {
  succeeded: false,
  tokens: kuromoji.IpadicFeatures[],
  error_reason: ErrorReason
}

type FailNotANoun = {
  succeeded: false,
  tokens: kuromoji.IpadicFeatures[],
  error_reason: 'NOT_A_NOUN',
  pos: string
}


// tokenが既知の単語であり、かつその品詞がposであるかどうか確認する
const posEqualsAndKnown = (token: kuromoji.IpadicFeatures, pos: string): boolean =>
  token.word_type === 'KNOWN' && token.pos === pos;

export const friendlyPos = (token: kuromoji.IpadicFeatures): string =>
  (token.word_type === 'UNKNOWN' ? '辞書に載っていない' : '') + token.pos;


const preprocess = (text: string): string =>
  kana_util.han2zen(text.trim());


export const verifyWord = (tokenizer: kuromoji.Tokenizer<kuromoji.IpadicFeatures>, text: string): VerifyWordResult => {
  text = preprocess(text);

  const tokens = tokenizer.tokenize(text);

  if (tokens.length === 1 && posEqualsAndKnown(tokens[0], '名詞')) {
    return {
      succeeded: true,
      tokens: tokens,
      surface: tokens[0].surface_form,
      kana: tokens[0].reading || ''
    };
  }
  else if (tokens.length === 1 && tokens[0].word_type === 'UNKNOWN') {
    return {
      succeeded: false,
      tokens: tokens,
      error_reason: 'NOT_A_WORD'
    }
  }
  // 1単語だが名詞ではないとき
  else if (tokens.length === 1) {
    return {
      succeeded: false,
      tokens: tokens,
      error_reason: 'NOT_A_NOUN',
      pos: friendlyPos(tokens[0])
    };
  }
  else {
    const allNoun = tokens.every(token => (posEqualsAndKnown(token, '名詞') || posEqualsAndKnown(token, '接頭詞')));
    // 入力が複合語と思われるとき
    if (allNoun) {
      return {
        succeeded: false,
        tokens: tokens,
        error_reason: 'COMPOUND_NOUN'
      };
    }
    // その他複数の単語
    else {
      return {
        succeeded: false,
        tokens: tokens,
        error_reason: 'NOT_A_WORD'
      };
    }
  }
}
<file_sep>/src/next-word.ts
import kuromoji from 'kuromoji'
import * as word_verifier from './word-verifier'
const freqlist = require('../rsc/freqlist_ja.json');

// ===== 次の言葉を考えるモジュール =====

function randomRanged(begin: number, end: number) {
  begin = Math.ceil(begin);
  end = Math.floor(end);
  return Math.floor(Math.random() * (end - begin) + begin);
}

const MAX_TRIAL = 12;

interface NextWordInfo {
  kana: string,
  word: string
}

const nextWord = (tokenizer: kuromoji.Tokenizer<any>, lastKana: string): NextWordInfo | undefined => {

  if (!freqlist[lastKana]) {
    console.assert(false, `not implemented next-word (lastKana: ${lastKana})`);
    return undefined;
  }

  let word;
  const numWords = freqlist[lastKana].length;

  let trial = 0;
  do {
    word = freqlist[lastKana][randomRanged(0, numWords)];
    ++trial;
    if (trial > 12) {
      word = undefined;
      break;
    }
  } while (!word_verifier.verifyWord(tokenizer, word[1]).succeeded);

  console.log("next-word trial", trial);

  if (!word) return undefined;

  return {
    kana: word[0],
    word: word[1]
  };
}

export default nextWord;<file_sep>/src/index.ts
import path from 'path'
import express from 'express'
import * as line from '@line/bot-sdk'
import kuromoji from 'kuromoji'
import * as word_verifier from './word-verifier'
import * as kana_util from './kana-util'
import next_word from './next-word'
import * as db from './db'

const dic_path = path.join(__dirname, '../node_modules/kuromoji/dict') + '/'

const port = process.env.PORT || 3000;

const config = {
  channelAccessToken: process.env.LINE_ACCESS_TOKEN || 'LINE ACCESS TOKEN IS NOT SET',
  channelSecret: process.env.CHANNEL_SECRET || 'CHANNEL SECRET IS NOT SET'
};

const client = new line.Client(config);

let tokenizer: kuromoji.Tokenizer<kuromoji.IpadicFeatures>;

const app = express();
app.use(express.text());

app.get('/', (req, res) => {
  res.sendStatus(200);
});

app.post('/webhook', line.middleware(config), (req, res) => {
  Promise
    .all(req.body.events.map(handleLineMessageEvent))
    .then(result => res.json(result))
    .catch(err => {
      console.error(err);
      res.status(500).end();
    });
});

app.post('/api', (req, res) => {
  console.log('api input:', req.body);

  const message = req.body || '';
  const userId = req.ip!;
  getReplies(message, userId)
    .then(responses => {
      res.json(responses).end();
    })
    .catch(err => {
      console.error(err);
      res.status(500).end();
    })
});

async function handleLineMessageEvent(event: line.MessageEvent) {
  console.log('line msg event:', JSON.stringify(event));

  const reply = (response: line.Message | line.Message[]) => client.replyMessage(event.replyToken, response);

  if (event.type !== 'message' || event.message.type !== 'text' || event.source.type !== 'user') {
    return Promise.resolve(null);
  }

  const responses = (await getReplies(event.message.text, event.source.userId))
    .map(res => ({ 'type': 'text', 'text': res } as line.Message));
  return client.replyMessage(event.replyToken, responses);
}

async function getReplies(message: string, userId: string): Promise<string[]> {
  const result = word_verifier.verifyWord(tokenizer, message);

  if (result.succeeded) {
    const firstKana = kana_util.firstKana(result.kana);
    const lastKana = kana_util.lastKana(result.kana);
    const wordInfoResponse = `単語: ${result.surface}、よみ: ${result.kana}、最後の文字は${lastKana}`;

    if (lastKana === 'ン') {
      await db.removeUserLastKana(userId);
      return [
        wordInfoResponse,
        'ンなので終了します',
        '再開するには適当な単語を言ってください'
      ];
    }

    const botLastKana = await db.obtainUserLastKana(userId);
    if (!botLastKana || firstKana === botLastKana) {
      const nextWord = next_word(tokenizer, lastKana);
      if (!nextWord) {
        return ['参りました'];
      }
      const nextBotLastKana = kana_util.lastKana(nextWord.kana);
      await db.updateUserLastKana(userId, nextBotLastKana);
      return [
        wordInfoResponse,
        `${nextWord.word} (${nextWord.kana} : ${nextBotLastKana}) `
      ];
    }
    else {
      return [
        `前の単語は${botLastKana}で終わりましたが、${message} (${result.kana}) は${firstKana}から始まります`,
        `${botLastKana}から始まる単語を入力してください`
      ];
    }

  }
  else if (result.error_reason === 'COMPOUND_NOUN') {
    const surfaces = result.tokens.map((token: any) => token.surface_form);
    return [
      `${message}は ${surfaces.join(' + ')} の複合語です。`,
      '実在する言葉かどうか判定できないので、複合語は使えません。'
    ];
  }
  else if (result.error_reason === 'UNKNOWN_WORD') {
    return [
      `${message}は辞書に載っていません。`,
      '辞書に載っている名詞しか使えません'
    ];
  }
  else if (result.error_reason === 'NOT_A_NOUN') {
    return [
      `「${message}」は${result.pos}です。`,
      '名詞しか使えません。'
    ];
  }
  else if (result.error_reason === 'NOT_A_WORD') {
    const tokens = result.tokens.map((token: any) => `${token.surface_form} : ${word_verifier.friendlyPos(token)}`);
    return [
      `形態素解析の結果、\n${tokens.join('\n')}\nとなりました。`,
      `「${message}」は名詞ではないのでしりとりには使えません。`
    ];
  }
  else {
    console.assert(false);
    return [`[実績解除] 有能デバッガー\nあなたはこのBOTの開発者が気づかなかったバグを見つけ出した！`];
  }
}

function getTokenizerPromise(): Promise<kuromoji.Tokenizer<kuromoji.IpadicFeatures>> {
  console.log('Loading Kuromoji.js');
  return new Promise((resolve) => {
    kuromoji.builder({ dicPath: dic_path }).build(function (err, tokenizer) {
      // tokenizer is ready
      resolve(tokenizer);
      console.log('Loaded Kuromoji.js');
      if (err) {
        console.error(err);
      }
    });
  })
}

getTokenizerPromise().then(tokenizer_ => {
  tokenizer = tokenizer_;
  app.listen(port, () => console.log(`Listening on :${port}`));
});<file_sep>/src/db.ts
import { Pool } from 'pg'

// ===== データベースに関するモジュール =====

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  ssl: { rejectUnauthorized: false }
});

export const updateUserLastKana = (userId: string, lastKana: string): Promise<void> => {
  const sql = 'INSERT INTO "UserLastLetter" (user_id, last_letter) VALUES ($1, $2) ON CONFLICT (user_id) DO UPDATE SET last_letter = $2';
  return pool
    .query(sql, [userId, lastKana])
    .then(res => {
      // pass
    })
    .catch(err => {
      console.error("エラー: updateUserLastKana()");
      console.error(err);
    });
}

export const obtainUserLastKana = (userId: string): Promise<string | undefined> => {
  const sql = 'SELECT last_letter from "UserLastLetter" WHERE user_id = $1 LIMIT 1';
  return pool
    .query(sql, [userId])
    .then(res => {
      return res.rows[0].last_letter;
    })
    .catch(_ => {
      // pass
      // ゲーム開始時は必ずエラーになるのでエラーログは出力しない
    });
}

export const removeUserLastKana = (userId: string): Promise<void> => {
  const sql = 'DELETE FROM "UserLastLetter" WHERE user_id = $1';
  return pool
    .query(sql, [userId])
    .then(_ => {
      // pass
    })
    .catch(err => {
      console.error("エラー: removeUserLastKana()");
      console.error(err);
    });
}
<file_sep>/src/kana-util.ts
// ===== 仮名に関するユーティリティモジュール =====

interface CharToCharMap {
  [key: string]: string;
}

const han2zenMap: CharToCharMap = {
  'ｶﾞ': 'ガ', 'ｷﾞ': 'ギ', 'ｸﾞ': 'グ', 'ｹﾞ': 'ゲ', 'ｺﾞ': 'ゴ',
  'ｻﾞ': 'ザ', 'ｼﾞ': 'ジ', 'ｽﾞ': 'ズ', 'ｾﾞ': 'ゼ', 'ｿﾞ': 'ゾ',
  'ﾀﾞ': 'ダ', 'ﾁﾞ': 'ヂ', 'ﾂﾞ': 'ヅ', 'ﾃﾞ': 'デ', 'ﾄﾞ': 'ド',
  'ﾊﾞ': 'バ', 'ﾋﾞ': 'ビ', 'ﾌﾞ': 'ブ', 'ﾍﾞ': 'ベ', 'ﾎﾞ': 'ボ',
  'ﾊﾟ': 'パ', 'ﾋﾟ': 'ピ', 'ﾌﾟ': 'プ', 'ﾍﾟ': 'ペ', 'ﾎﾟ': 'ポ',
  'ｳﾞ': 'ヴ', 'ﾜﾞ': 'ヷ', 'ｦﾞ': 'ヺ',
  'ｱ': 'ア', 'ｲ': 'イ', 'ｳ': 'ウ', 'ｴ': 'エ', 'ｵ': 'オ',
  'ｶ': 'カ', 'ｷ': 'キ', 'ｸ': 'ク', 'ｹ': 'ケ', 'ｺ': 'コ',
  'ｻ': 'サ', 'ｼ': 'シ', 'ｽ': 'ス', 'ｾ': 'セ', 'ｿ': 'ソ',
  'ﾀ': 'タ', 'ﾁ': 'チ', 'ﾂ': 'ツ', 'ﾃ': 'テ', 'ﾄ': 'ト',
  'ﾅ': 'ナ', 'ﾆ': 'ニ', 'ﾇ': 'ヌ', 'ﾈ': 'ネ', 'ﾉ': 'ノ',
  'ﾊ': 'ハ', 'ﾋ': 'ヒ', 'ﾌ': 'フ', 'ﾍ': 'ヘ', 'ﾎ': 'ホ',
  'ﾏ': 'マ', 'ﾐ': 'ミ', 'ﾑ': 'ム', 'ﾒ': 'メ', 'ﾓ': 'モ',
  'ﾔ': 'ヤ', 'ﾕ': 'ユ', 'ﾖ': 'ヨ',
  'ﾗ': 'ラ', 'ﾘ': 'リ', 'ﾙ': 'ル', 'ﾚ': 'レ', 'ﾛ': 'ロ',
  'ﾜ': 'ワ', 'ｦ': 'ヲ', 'ﾝ': 'ン',
  'ｧ': 'ァ', 'ｨ': 'ィ', 'ｩ': 'ゥ', 'ｪ': 'ェ', 'ｫ': 'ォ',
  'ｯ': 'ッ', 'ｬ': 'ャ', 'ｭ': 'ュ', 'ｮ': 'ョ',
  '｡': '。', '､': '、', 'ｰ': 'ー', '｢': '「', '｣': '」', '･': '・'
};
const han2zenReg = new RegExp('(' + Object.keys(han2zenMap).join('|') + ')', 'g');

const small2bigMap: CharToCharMap = {
  'ァ': 'ア', 'ィ': 'イ', 'ゥ': 'ウ', 'ェ': 'エ', 'ォ': 'オ',
  'ヵ': 'カ', 'ヶ': 'ケ', 'ッ': 'ツ',
  'ャ': 'ヤ', 'ュ': 'ユ', 'ョ': 'ヨ', 'ヮ': 'ワ'
};
const small2bigReg = new RegExp('(' + Object.keys(small2bigMap).join('|') + ')', 'g');

// textの中の半角の文字(英数字・カタカナ)を全角に変換する
export const han2zen = (text: string) => {
  return text
    .replace(han2zenReg, c => han2zenMap[c])
    .replace(/[A-Za-z0-9]/g, c => String.fromCharCode(c.charCodeAt(0) + 0xFEE0))
    .replace(/ﾞ/g, '゛')
    .replace(/ﾟ/g, '゜');
}

// 小さいカタカナを大きいカタカナにする
export const small2big = (text: string) =>
  text.replace(small2bigReg, c => small2bigMap[c]);


// 最初のカタカナを取得する
// kanaTextはカタカナと長音(ー)のみから成る文字列
// 最初のカタカナとは
// リンゴ -> リ
// ジャンプ -> ジ
export const firstKana = (kanaText: string) => kanaText[0];

// 最後のカタカナを取得する
// kanaTextはカタカナと長音(ー)のみから成る文字列
// 最後のカタカナとは
// バナナ -> ナ
// リンゴ -> ゴ
// トレーナー -> ナ
// ニンジャ -> ヤ
// ジンジャー -> ヤ
// ウワッ -> ツ
export const lastKana = (kanaText: string) => {
  while (kanaText.slice(-1) === 'ー') {
    kanaText = kanaText.slice(0, -1);
  }
  const lastKana = kanaText.slice(-1);
  return small2big(lastKana);
}
