LoginSignup
1

More than 1 year has passed since last update.

posted at

updated at

[救急車を呼ぶかどうか迷った時に]LINEボットでも救急相談の電話番号が知りたい!

救急相談 #7119が繋がらなかったので現在位置から電話番号を返すbotを作った

なぜ作ったか

みなさんこんにちは。
突然ですが、救急車を呼ぶかどうか迷った時にかける電話番号、すぐに出てきますか? (私は出てきませんでした。。)

先週、親しい人から聞いた話
朝起きたら両足が痙攣して痛みで動けず、一人暮らしで誰もおらず
意識ははっきり、曰く「こんなんで救急車呼んで怒られないかしら」と
ぐぐると、#7119がヒットし、電話するも「おかけになった電話番号は現在使われておりません」
結局、10分くらい調べて119へ。

救急車の適正利用が叫ばれて久しい昨今ですが
一人暮らしで身動きが取れなくなると近くの病院を探すのも大変です。

確かに少し調べると、大人 #7119 小児 #8000 と出てくるのですが、
実は#7119は全国の約半分で、#8000は全国対応ですが夜間時間帯しか繋がりません。

#7119 の対応範囲

実は#7119 非対応な地域でも色々と工夫を凝らしていて、例えば千葉県は #7009 川崎市は 044-739-1919 など24時間対応だったりそうじゃなかったり、地域によってあったりなかったりします。
(そして、いざという時ほど公式サイトやポータルサイトが引っかからないものです)

作ったものと使い方

ソースコードと全国の電話番号一覧(救急病院案内・大人・小児)

Github

LINEアカウント

位置情報を送信すると、「医療機関を探すためのリンク」「大人向け緊急相談電話番号」「小児向け電話番号」をメッセージで返します。

友達になる

↓こんな感じ
送信後 大人 小児 医療機関案内

技術的なこと

全体的な構成

サーバー的なこと

シンプルにWebhookをLambda(Rust)で受けて、Google Map APIで地方自治体名を取得しています。

モジュール構造的なこと

$ tree -L 2
.
├── api.zip    // アップロードするバイナリ
├── Cargo.lock
├── Cargo.toml
├── conf
│   ├── dev.yml // 環境変数(テスト用)
│   └── prd.yml // 環境変数(本番用)
├── images      // スクショ置き場
├── Makefile    // ビルド・デプロイコマンド
├── README.md
├── richmenu
│   ├── richmenu.png  // リッチメニューの画像
│   └── richmenu.py   // リッチメニューを設定するスクリプト
├── serverless.yml
├── src
│   ├── application
│   │   ├── line_callback.rs  // コールバックのロインロジック
│   │   └── mod.rs
│   ├── bin
│   │   ├── api.rs            // エントリポイント
│   │   └── print_markdown.rs // 全国の電話番号と対応時間一覧のmarkdown出力コマンド
│   ├── lib.rs
│   ├── model
│   │   ├── area.rs           // 基本的なデータ型
│   │   ├── error.rs
│   │   └── mod.rs
│   ├── repository
│   │   ├── area.rs           // 現在位置から対応する地域情報を探すロジック(データはベタ書き)
│   │   └── mod.rs
│   └── service
│       ├── google_map_api.rs // Google Map API呼ぶところ
│       └── mod.rs
├── target
│   ├── debug
│   └── x86_64-unknown-linux-musl
└── vendor
    └── line-bot-sdk-rust

ロジック的な話

処理の流れとしては単純で

  1. 事前に地域ごとの設定情報を作る (今回はプログラムにベタ書き)
  2. LINEから位置情報イベントが飛んでくる
  3. 緯度経度をGoogle Map APIに渡して都道府県と市区町村を取得する。
  4. 都道府県と市区町村から対応する設定情報を取得する。
  5. テキストを整形してメッセージを送信する。(reply_message)

という感じです。
順番に見ていきましょう。

環境情報的なこと

必要な環境変数はLINEボットに必要なパラメータとGoogle Map APIのkeyだけです。

conf/dev.yml
ENV: "dev"
LINE_BOT_SECRET: (LINE developers -> 対象のmessaging api channel -> シークレットキー)
LINE_BOT_ACCESS_TOKEN: (LINE developers -> 対象のmessaging api channel -> messaging api タブ -> 一番下の長い文字列)
GOOGLE_API_KEY: (GCPから生成するやつ)

地域ごとの情報を作る

地方自治体系で困るのは、「データをどう持たせるか」というところです。
「24時間365日対応です。以上!」 なら何の問題もないのですが、
例えば、宮城県の#7119は、平日 : 午後7時から翌8時 土曜 : 午後2時から翌8時 日曜・祝日 : 午前8時から翌8時 のようになっています。
(GWとか年末年始とか今回は流石に無視しています。曜日判定や祝祭日判定も大変なので今回は実装していません。)

翌8時のような表記が曲者で、内部的には32時と表現する形にしました。
なので、対象の都道府県、市区町村(複数可)、サイトへのリンク、対応時間、電話番号、と必要そうなものをひとまとめにします。
(timeのTimeTypeについては次の Rustの良いところ で詳しく説明します)

        AreaHelpLine {
            pref: "宮城県",
            cities: Vec::new(),
            hp: "おとな救急電話相談について - 宮城県公式ウェブサイト",
            url: "https://www.pref.miyagi.jp/soshiki/iryou/kyuukyuutel.html",
            time: TimeType::WeekHoliday { // 土日祝型
                w_from: MyTime(19),  // 平日 19時から
                w_to: MyTime(32),    // 平日 翌8時まで
                s_from: MyTime(14),  // 土曜 14時から
                s_to: MyTime(32),    // 土曜 翌8時まで
                h_from: MyTime(8),   // 日祝 8時から
                h_to: MyTime(32),    // 日祝 翌8時まで
            },
            phone: vec!["#7119", "022-706-7119"],
        },

はい、今回はデータ構造自体はそんなに複雑ではないですね。
あとは47都道府県分、医療期間探すサイト、大人用、小児用、あちこち探し回ってデータを作ります。
(データは src/repository/area.rs にベタ書き)

LINEから位置情報を取得する

まず、ユーザに位置情報を送信してもらうため、リッチメニューを作ります。

画像は最近流行のcanvaで適当に作成しました。(リッチメニューのテンプレートまであってびっくりです)
一番左のボタンを位置情報送信イベントにして、真ん中と右のボタンはただのリンクにします。
位置情報を送信させるには、URIアクションを使います。

richmenu/richmenu.py
def create_user_richmenu(line_bot_api):
    print("create user richmenu")
    user_menu = RichMenu(
        size=RichMenuSize(width=2500, height=843),
        selected=True,
        name="qq_sodan_doko user menu",
        chat_bar_text="メニューはこちら",
        areas=[
            RichMenuArea(
                bounds=RichMenuBounds(
                    x=0,
                    y=0,
                    width=833,
                    height=843,
                ),
                action=URIAction(
                    label='location',
                    uri="https://line.me/R/nv/location/",
                ),
            ),
            RichMenuArea(
                bounds=RichMenuBounds(
                    x=833,
                    y=0,
                    width=833,
                    height=843,
                ),
                action=URIAction(
                    label='qsuke',
                    uri="http://www.fdma.go.jp/neuter/topics/filedList9_6/kyukyu_app/kyukyu_app_web/index.html",
                ),
            ),
            RichMenuArea(
                bounds=RichMenuBounds(
                    x=1666,
                    y=0,
                    width=833,
                    height=843,
                ),
                action=URIAction(
                    label='hp',
                    uri="https://github.com/tokishirazu-llc/qq_sodan_doko",
                ),
            ),
        ]
    )
    id = line_bot_api.create_rich_menu(rich_menu=user_menu)
    print("user richmenu id = "+id)
    return id

# 画像をアップロードする
def upload_user_richmenu_image(line_bot_api, id):
    print("update user richmenu "+ id)
    with open('richmenu/richmenu.png', 'rb') as f:
        line_bot_api.set_rich_menu_image(id, 'image/png', f)

LINEのイベント処理は、メッセージタイプの位置情報を処理します。

for event in events.events {
    match event.r#type {
        EventType::MessageEvent(message) => match message.message.r#type {
            MessageType::LocationMessage(location) => {
                bot.reply_message(&message.reply_token, /* 位置情報からデータを取得してテキストメッセージを返す */)
           }
        }
    }
}

次にLINEから送信される位置情報は公式マニュアル の通りですが、なぜかtitleは含まれていませんでした。

      "message": {
        "id": "325708",
        "type": "location",
        "title": "my location", // ←これが送られてこない
        "address": "日本、〒160-0004 東京都新宿区四谷1丁目6−1",
        "latitude": 35.687574, // 緯度
        "longitude": 139.72922 // 経度
      }

使うのは latitudelongitude です。
addressに都道府県と市区町村が入っていますが、パースしにくいのでGoogle Map APIを使うことにしました。
なお、経済産業省の住所正規化npmという素晴らしいものもありますが、dbを動かす必要がありLambdaと相性が良くないので使えませんでした。

Google Map API を呼んで都道府県と市区町村を取得する

Google Map API のジオコーディングを使います。
事前にGCPからAPIキーを取得しGETするだけです。

src/service/google_map_api.rs
    format!(
        "https://maps.googleapis.com/maps/api/geocode/json?language=ja&latlng={},{}&key={}",
        lat,
        lng,
        env::var("GOOGLE_API_KEY").unwrap()
    )

// 上のURLでGETする
    let response: GoogleMapResponse = block_on(
        block_on(
            reqwest::Client::new()
                .get(&get_map_endpoint(lat, lng))
                .send(),
        )?
        .json(),
    )?;

// administrative_area_level_1 が都道府県、localityが市区町村、郡については未確認
   let mut area = Area {
        pref: "".to_string(),
        city: "".to_string(),
    };
    response
        .results
        .into_iter()
        .flat_map(|r| r.address_components.into_iter())
        .for_each(|address_component| {
            let types = address_component.types;
            if types.contains(&"administrative_area_level_1".to_string()) {
                area.pref = address_component.long_name;
            } else if types.contains(&"locality".to_string()) && !types.contains(&"colloquial_area".to_string()) {
                area.city = address_component.long_name;
            }
        });

都道府県と市区町村からデータを取得する

この辺りは都道府県と市区町村でデータを探して、両方とも一致するものがあればそれを、なければ都道府県だけ一致するものを返します。
例えば、東京都だと23区と多摩地区で一般回線用の電話番号が異なるため、データを分けています。

src/repository/area.rs
// 大人用データ
        AreaHelpLine {
            pref: "東京都",
            cities: vec![
                "千代田区", ... // 23区列挙
            ],
            hp: "東京消防庁(23区)",
            url: "https://www.tfd.metro.tokyo.lg.jp/lfe/kyuu-adv/soudan-center.htm",
            time: TimeType::Allday {
                from: MyTime(0),
                to: MyTime(24),
            },
            phone: vec!["#7119", "03-3212-2323"], // こっちは03
        },
        AreaHelpLine {
            pref: "東京都",
            cities: Vec::new(),
            hp: "東京消防庁(多摩地域)",
            url: "https://www.tfd.metro.tokyo.lg.jp/lfe/kyuu-adv/soudan-center.htm",
            time: TimeType::Allday {
                from: MyTime(0),
                to: MyTime(24),
            },
            phone: vec!["#7119", "042-521-2323"], // 電話番号が異なる
        },

そのため、市区町村レベルで一致すればそちらを優先的に案内するようにしています。(伊豆諸島の方は、、、多分地域医療がしっかりしてるからこんなbot要らないかしら)

fn get_help_line_from(data: fn() -> Vec<AreaHelpLine>, area: &Area) -> HelpLineType {
    match data()
        .into_iter()
        // 対象のエリアがある場合
        .find(|help_line| {
            area.pref == help_line.pref && help_line.cities.contains(&area.city.as_str())
        })
        // 都道府県がやっているサービス
        .or_else(|| {
            data()
                .into_iter()
                .find(|help_line| area.pref == help_line.pref && help_line.cities.is_empty())
        }) {
        // 情報がある場合
        Some(help_line) => {
          // 全日なら、今やってるか判断する
            return match &help_line.time {
                TimeType::Allday { .. } => {
                    if help_line.time.in_now() {
                        // やってるかも!
                        HelpLineType::InService(help_line)
                    } else {
                        // やってないかも!
                        HelpLineType::OutOfTime(help_line)
                    }
                }
                // 曜日によって対応時間が違う場合は自分で判断してね
                TimeType::WeekHoliday { .. } => HelpLineType::UnknownTime(help_line),
            };
        }
        None => {}
    };

    // 情報がない。地元に要望を出しましょう!
    HelpLineType::None
}

テキストを整形してメッセージを返す

LINE botではリプライは無料です。
公式マニュアルによると一度に5通まで送信できます。

今回は、医療機関を探す、大人用、小児用、でそれぞれテキストを整形してメッセージを返します。

// イベントループで位置情報を受けとった後
                bot.reply_message(
                    &message.reply_token,
                    match get_address_from_latlng(location.latitude, location.longitude) {
                        Ok(area) => {
                            let (anai, adult, children) = get_help_line_message(area);
                            vec![
                                SendMessageType::TextMessage(TextMessage {
                                    text: anai,
                                    emojis: None,
                                }),
                                SendMessageType::TextMessage(TextMessage {
                                    text: adult,
                                    emojis: None,
                                }),
                                SendMessageType::TextMessage(TextMessage {
                                    text: children,
                                    emojis: None,
                                }),
                            ]
                        }
                        Err(err) => {
                            error!("{}", err);
                            vec![SendMessageType::TextMessage(TextMessage {
                                text: format!("位置情報から住所を取得できませんでした"),
                                emojis: None,
                            })]
                        }
                    },
                )
                .unwrap();

以上で、出来上がりです。
ビルドやデプロイ周りについては Makefileserverless.ymlをご覧ください。

line-bot-sdk-rustについて

Rust向けのLINE Bot SDKを作ってくださっている方がいたので、今までGoでbotを作っていたのをRsutにしてみたのですが、
開発が止まっているようでそのままではビルドが通らないので適当に修正して使いました。
githubにパッチを入れておきましたが、メンテも大変そうですし早く公式で出て欲しいものです。
タイプアサーションがenumで書けるのでしっかりコンパイルチェックが通るからGoより向いてると思うんですよね。

Rustの良いところ

全日と土日祝をenumで分けて処理できる!

例えば、宮崎県は#7119でも対応時間は前途の通りですが、横浜市は全日24時間対応です。
処理する時や表示するときに、 if w_from == s_from && s_from == h_from && _toも全て等しいなら { "全日" } else {...} とかやってられません。
そこで enum で全日型と平日土日祝型を分けて処理します。

// 平日と休日で時間が異なることがあるので
pub enum TimeType {
    Allday {
        from: MyTime,
        to: MyTime,
    },
    WeekHoliday {
        w_from: MyTime,
        w_to: MyTime,
        s_from: MyTime,
        s_to: MyTime,
        h_from: MyTime,
        h_to: MyTime,
    },
}

データはこのように分けて実装できます。

// 大人用の設定
        AreaHelpLine {
            pref: "宮城県",
            cities: Vec::new(),
            hp: "おとな救急電話相談について - 宮城県公式ウェブサイト",
            url: "https://www.pref.miyagi.jp/soshiki/iryou/kyuukyuutel.html",
            time: TimeType::WeekHoliday { // 土日祝型
                w_from: MyTime(19),  // 平日 19時から
                w_to: MyTime(32),    // 平日 翌8時まで
                s_from: MyTime(14),  // 土曜 14時から
                s_to: MyTime(32),    // 土曜 翌8時まで
                h_from: MyTime(8),   // 日祝 8時から
                h_to: MyTime(32),    // 日祝 翌8時まで
            },
            phone: vec!["#7119", "022-706-7119"],
        },
...
        AreaHelpLine {
            pref: "神奈川都",
            cities: vec!["横浜市"],
            hp: "横浜市救急医療センター 救急電話相談",
            url: "https://www.yokohama-emc.jp/pc/syouni/syouni.html",
            time: TimeType::Allday {
                from: MyTime(0),
                to: MyTime(24),
            },
            phone: vec!["#7119", "045-232-7119"],
        },

つまり、データ型に意味を持たせることができるので、
探した結果をこう定義して

ub enum HelpLineType {
    InService(AreaHelpLine),   // 今やってるかも!
    UnknownTime(AreaHelpLine), // わからない
    OutOfTime(AreaHelpLine),   // 時間外かも!
    None,                      // 情報が見つからなかった。。。
}

こう返して

        match 探す() {
        // 情報がある場合
        Some(help_line) => {
          // 全日なら、今やってるか判断する
            return match &help_line.time {
                TimeType::Allday { .. } => {
                    if help_line.time.in_now() {
                        // やってるかも!
                        HelpLineType::InService(help_line)
                    } else {
                        // やってないかも!
                        HelpLineType::OutOfTime(help_line)
                    }
                }
                // 曜日によって対応時間が違う場合は自分で判断してね
                TimeType::WeekHoliday { .. } => HelpLineType::UnknownTime(help_line),
            };

メッセージ処理をすっきり書くことができます!

        format!(
            "大人(有人対応)\n{}",
            match adult {
                HelpLineType::InService(help_line) => {
                    format!("今相談できるようです。\n\n{}", help_line)
                }
                HelpLineType::UnknownTime(help_line) => {
                    format!("曜日によって対応時間が異なります。\n\n{}", help_line)
                }
                HelpLineType::OutOfTime(help_line) => {
                    format!("残念ながら時間外かもしれません\n\n{}", help_line)
                }
                HelpLineType::None => {
                    format!("残念ながら相談窓口はないようです。")
                }
            },
        ),

fmt::Displyaで、午前x時、午後x時、翌x時の出力処理をすっきり分けられる!

これのおかげで表示処理がすっきりします。
まあ、他の言語でもto_string()みたいなものを実装すれば良いのですが
演算子のオーバーロードとかすっきりさせるメリットは大きいです。

help_line は AreaHelpLine型 この情報をテキストにしたい。

format!("今相談できるようです。\n\n{}", help_line)

するとこれが呼ばれる

impl fmt::Display for AreaHelpLine {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(
            f,
            "{}\n{}\n\n{}\n\n{}",
            self.hp,
            self.url,
            match &self.time {
                TimeType::Allday { from, to } => {
                    format!(
                        "[全日]\n{}",
                        if from.0 == 0 && to.0 == 24 {
                            String::from("24時間")
                        } else {
                            format!("{}〜{}", from, to)
                        }
                    )
                }
                TimeType::WeekHoliday {
                    w_from,
                    w_to,
                    s_from,
                    s_to,
                    h_from,
                    h_to,
                } => {
                    format!(
                        "[平日]\n{}〜{}\n[土曜]\n{}〜{}\n[休日]\n{}〜{}",
                        w_from, w_to, s_from, s_to, h_from, h_to
                    )
                }
            },
            format!("[電話]\n{}\n", self.phone.join(" または\n"),),
        )
    }
}

で、さらに from, to を午前x時とか翌x時とか表示させたいので
0から12なら午前、13から24なら午後、それ以上なら24引いて翌x時と表示する。

// fromとかtoはこの型
pub struct MyTime(pub u8);
impl fmt::Display for MyTime {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(
            f,
            "{}",
            match self.0 {
                0..=12 => format!("午前{}時", self.0),
                13..=24 => format!("午後{}時", self.0 - 12),
                _ => format!("翌{}時", self.0 - 24),
            }
        )
    }
}

まあ、Railsとかでもそうですがオーバーロードは便利な反面、多用しすぎるとコードが追いづらくなるというのもあったりします。

逆に下記のコードはトレイトを色々実装するのが面倒なので妥協した箇所です。

// 今やってるかどうか判別するコード
impl TimeType {
    pub fn in_now(&self) -> bool {
        match self {
            Self::Allday { from, to } => {
          // 現在時刻を0から23で取得する
                let now = TryFrom::try_from(
                    Utc::now()
                        .with_timezone(&FixedOffset::east(9 * 3600))
                        .hour(),
                )
                .unwrap();
                // この辺りで .0をいちいち書きたくないがトレイトを書く方が面倒
                if to.0 < 24 {
                    from.0 <= now && now < to.0
                } else {
                    from.0 <= now || now < to.0 - 24
                }
            }
            _ => false, // 今日が平日か休日かがわからないため、判定しない
        }
    }
}

後書き

抜け漏れや医療機関ポータルのリンクなど纏まっていないところがあります。

頑張っている地方自治体さん

  • 埼玉県、茨城県 : 大人も子供も24時間対応はここだけ!
  • 和歌山県田辺市 : 和歌山県でなぜかここだけ #7119 対応してる
  • 山口県萩市、阿武町 : 24時間365日、日頃の健康や医療、育児に関する相談にも応じます、とのこと

ただの愚痴

親愛なる日本政府及び地方自治体さまへ
いつも大変お世話になっております。
デジタル超やるならこれやってください。

  • もうそろそろhttpsにしませんか?
  • 全角・半角・画像・03(1111)2222 表記。統一してくれませんか?
  • お知らせ系のページではなくポータルサイトにリンク貼ってくれませんか (リンク切れになる)
  • どこが一次ソースですか? (各地方自治体 > 総務省消防庁、厚生労働省 > 各地方自治体医療ネットワーク > 民間のまとめサイト系 の順番?)
  • 厚生労働省さんもっと頑張って! 24時間対応と大人対応もやって! (全国民に生態情報取得デバイスつけてAIで支配するんでしょ!? (違う)
  • #7119の予算、20億円ですか。

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
What you can do with signing up
1