0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Misskeyで天気予報botを作ってみた─2

0
Posted at

概要

自分が管理しているMisskeyサーバーに毎朝天気予報を投稿するbotがあるのですが、それを最近Rustで書き直しました。
「Misskeyで」と言うと語弊があるかもしれませんが、Rustで書いたコードをAWS Lambdaにデプロイして、そこからMisskeyにノートを作成するという処理になっています。
以前書いた記事: https://qiita.com/maeda6uiui/items/5d0bf69966d74b8a9cf7

以前の記事で作成したコードは、GitHub ActionsのログからMisskeyとWeather APIのアクセストークンを流出させるという不祥事を起こしたため非公開になっています。
使用しているAPIなどは前回と大体同じなのですが、大きく異なる点として、botのコードをRustで書き直しています(これまで運用していたbotはPython)。
なんとなく「Rustを使ってみたいなー」程度のモチベーションでしたが...。

コードはGitHubで公開しています。
前回のような不祥事が起こらない限りはPublicにしておきます。
不祥事が起こらないことを祈っていてください。
https://github.com/maeda6uiui/misskey-weather-bot-2

コードの細かいところはGitHubのリポジトリを確認してもらうとして、この記事では自分が気になったところをメモ程度に残しておきます。

スクリーンショット

Screenshot from 2026-04-18 11-06-21.png

開発者の環境

OS: Linux Mint 22.3

Zorin OSからLinux Mintに乗り換えました。
Zorin OS 17から18への更新がなかなか利用可能にならなかったので、「Zorin OS 18をクリーンインストールするか」と思いましたが、せっかくクリーンインストールするなら別のdistroを試してみよう、ということで、有名どころのLinux Mintをインストールしてみました。
個人的にかなり使いやすくて気に入っています。

アプリ

Lambdaで動かすコードです。
コードはweather-botディレクトリに格納されています。

コードはいくつかのモジュールに分割されています。

モジュール 概要
aws AWSリソースを操作する
config 設定値を管理する
emoji_converter Weather APIの結果から対応する絵文字を取得する
misskey_client Misskeyにノートを作成する
note_text_generator Weather APIの結果からMisskeyに投稿するテキストを作成する
weather_api_client Weather APIを実行する

ディレクトリ構成は以下のとおりです。

.
├── Cargo.lock
├── Cargo.toml
├── Data
│   └── weather_conditions.csv
├── Dockerfile
├── README.md
├── run_locally.sh
└── src
    ├── aws
    │   ├── config.rs
    │   └── ssm.rs
    ├── aws.rs
    ├── config
    │   └── main.rs
    ├── config.rs
    ├── emoji_converter
    │   └── main.rs
    ├── emoji_converter.rs
    ├── main.rs
    ├── misskey_client
    │   ├── entity.rs
    │   └── main.rs
    ├── misskey_client.rs
    ├── note_text_generator
    │   └── main.rs
    ├── note_text_generator.rs
    ├── weather_api_client
    │   ├── entity.rs
    │   └── main.rs
    └── weather_api_client.rs

それぞれのモジュールについて簡単に説明しておきます。

aws

awsモジュールには、Parameter Storeからパラメータを取得するためのコードが入っています。
前回のコードでGitHub Actionsの実行ログにアクセストークンが出力されてしまった原因の一つとして、Lambdaの環境変数にアクセストークンを直接設定していたことが挙げられます。
このため、今回の構成ではAWS Systems Manager Parameter Storeにアクセストークンを設定し、Lambdaの環境変数にはパラメータのパスを設定することにします。

今回のコードではenumを使って独自のエラーを定義しています。
thiserror::Errorをderiveしています。

#[derive(Debug, Error)]
pub enum SsmClientError {
    #[error("sdk error: {0}")]
    SdkError(String),
    #[error("no parameter found: {0}")]
    NoParameterFoundError(String),
    #[error("no value found: {0}")]
    NoValueFoundError(String),
}

impl<T> From<SdkError<T>> for SsmClientError {
    fn from(value: SdkError<T>) -> SsmClientError {
        SsmClientError::SdkError(value.to_string())
    }
}

何らかのエラーからthiserror::Errorをderiveしたエラーに変換したい場合、わざわざ自前でFrom traitを実装しなくても、#[from]を使用すればいいです。
ただ、AWS SDKが返すSdkErrorはgenericsを使用しており、これを#[from]と合わせて使用する方法がわかりませんでした。
もしかするとできるのかもしれませんが、今回は自前でFrom traitを実装しています。
以上の対応により、Result<String, SsmClientError>を返す関数の中で?演算子を使用できるようになります。

参考までに、EmojiConverterErrorでは#[from]を用いてPolarsのエラーをEmojiConverterErrorに変換しています。

#[derive(Error, Debug)]
pub enum EmojiConverterError {
    #[error("no matching emoji found")]
    NoMatchingEmojiFound,
    #[error("polars error: {0}")]
    Polars(#[from] polars::error::PolarsError),
}

config

Weather APIのクエリやMisskeyサーバーのURLなどの設定を読み込む処理です。
ローカル環境での実行時とLambdaでの実行時で値の取得元が異なるので、featureフラグを用いて処理を分岐させています。

pub async fn new(override_args: Option<LocalArgs>) -> Result<Self, ConfigError> {
    if cfg!(feature = "local") {
        return Self::load_locally(override_args);
    } else if cfg!(feature = "default") {
        return Self::load_on_lambda().await;
    } else {
        return Err(ConfigError::UnknownRuntimeError);
    }
}

このモジュールにはテストコードもあります。
処理本体と同じファイルにtestsモジュールを追加してあります。

環境変数の有無による挙動を確認したいですが、環境変数の追加と削除はマルチスレッド環境では安全ではない(unsafe)です。
一方で、各テストケースはデフォルトでは並列に実行されるので、このままではテストコードの動作が不安定になってしまいます。
ChatGPTに質問したところ、serial_testというcrateをおすすめしてくれたので、これを使うことにしました。
使い方は簡単で、直列で実行したいテストケースに#[serial]を指定するだけです。

#[tokio::test]
#[serial]
async fn load_locally_success() {
    
}

emoji_converter

Weather APIの実行結果から対応する絵文字を取得する処理です。
絵文字の一覧表はCSVファイルとして用意してあります。
CSVファイルを読み込んでPolarsのDataFrameを作成し、そこからWeather APIのcodeに対応する絵文字を返す処理になっています。

misskey_client

MisskeyのRust SDKはあったのですが、メンテナンスされていない感じがしたので、今回はMisskeyのAPIを直接実行することにしました。
今回はノート作成の機能だけあればいいので、最小限のrequestとresponseを定義しています。

misskey_client/entity.rs
use serde::{Deserialize, Serialize};

pub enum NoteVisibility {
    Public,
    Home,
    Followers,
    Direct(Vec<String>),
}

#[derive(Deserialize)]
pub struct Note {
    pub id: String,
}

#[derive(Serialize)]
pub struct CreateNoteRequest {
    pub visibility: String,
    #[serde(rename = "visibleUserIds")]
    pub visible_user_ids: Vec<String>,
    pub text: String,
}

#[derive(Deserialize)]
pub struct CreateNoteResponse {
    #[serde(rename = "createdNote")]
    pub created_note: Note,
}

NoteVisibility::DirectはDM送信先のユーザーIDを値に取ります。
ここで空のVecを指定すれば、本人だけが確認できるノートとなります。
開発中の動作確認ではこの機能を使用していました。

HTTPリクエストの実行にはreqwestを使用しています。
特筆すべき点はなさそうですが、一応コード全体を掲載しておきます。

misskey_client/main.rs
use std::time::Duration;

use reqwest::{
    Client, Url,
    header::{self, HeaderMap, HeaderValue, InvalidHeaderValue},
};
use thiserror::Error;

use crate::misskey_client::entity::{CreateNoteRequest, CreateNoteResponse, NoteVisibility};

pub struct MisskeyClient {
    server_url: String,
    access_token: String,
    http_client: Client,
}

#[derive(Debug, Error)]
pub enum MisskeyClientError {
    #[error("http client error: {0}")]
    HttpClientError(#[from] reqwest::Error),
    #[error("invalid header value: {0}")]
    InvalidHeaderValueError(#[from] InvalidHeaderValue),
    #[error("url parse error: {0}")]
    UrlParseError(String),
}

impl MisskeyClient {
    pub fn new(server_url: &str, access_token: &str) -> Result<Self, MisskeyClientError> {
        let http_client = Client::builder().timeout(Duration::from_secs(10)).build()?;
        Ok(MisskeyClient {
            server_url: server_url.to_string(),
            access_token: access_token.to_string(),
            http_client,
        })
    }

    pub async fn create_note(
        &self,
        text: &str,
        visibility: NoteVisibility,
    ) -> Result<CreateNoteResponse, MisskeyClientError> {
        let mut headers = HeaderMap::new();
        headers.insert(
            header::CONTENT_TYPE,
            HeaderValue::from_static("application/json"),
        );
        headers.insert(
            header::AUTHORIZATION,
            HeaderValue::from_str(format!("Bearer {}", &self.access_token).as_str())?,
        );

        let str_visibility = match visibility {
            NoteVisibility::Public => "public",
            NoteVisibility::Home => "home",
            NoteVisibility::Followers => "followers",
            NoteVisibility::Direct(_) => "specified",
        };
        let mut visible_user_ids: Vec<String> = Vec::new();
        if let NoteVisibility::Direct(user_ids) = visibility {
            user_ids
                .iter()
                .for_each(|v| visible_user_ids.push(v.to_string()));
        };

        let request = CreateNoteRequest {
            visibility: str_visibility.to_string(),
            visible_user_ids,
            text: text.to_string(),
        };

        let endpoint = format!("{}/api/notes/create", &self.server_url);
        let url = match Url::parse(&endpoint) {
            Ok(v) => Ok(v),
            Err(e) => Err(MisskeyClientError::UrlParseError(e.to_string())),
        }?;

        let response = self
            .http_client
            .post(url)
            .headers(headers)
            .json(&request)
            .send()
            .await?
            .json::<CreateNoteResponse>()
            .await?;
        Ok(response)
    }
}

note_text_generator

Weather APIの実行結果からMisskeyに投稿するテキストを生成します。

テキストを作成するときにindocというcrateが便利だったので紹介しておきます。
Misskeyに投稿するテキストはindoc::formatdoc!を用いて以下のように作成しています。
indoc::formatdoc!format!に対応するマクロで、複数行文字列の行の始まりを揃えてくれます。

let text = indoc::formatdoc! {
    r#"
    [{date}] Weather forcast in {location}
    {condition_emoji}{condition_text}
    {avgtemp_c} ℃ (avg) / {mintemp_c} ℃ (min) / {maxtemp_c} ℃ (max)
    ---
    🌄 {sunrise} - {sunset}
    🌕 {moonrise} - {moonset} / {moon_phase}
    "#
};

ここで単純にformat!を使うと、以下のように行の始めに余計な空白が入ってしまいます。

[2026-03-29] Weather forcast in Tokyo
            ☀Sunny
            16.1 ℃ (avg) / 12.7 ℃ (min) / 20.4 ℃ (max)

空白を削除する処理を自前で実装しようとしていたところ偶然このcrateを見つけて、使ってみると地味に便利でした。

weather_api_client

Weather APIを実行して天気予報を取得します。
Weather APIに対してgetリクエストを投げる処理で、misskey_clientと同じくreqwestを使用しています。

main.rs

プログラムのエントリーポイントです。
これまで運用していたPythonコードではローカル実行用(main_local.py)とLambda実行用(main.py)の二つのファイルがありましたが、今回のRustコードではfeature flagを用いてどちらのmain関数を用いるか制御します。

#[cfg(feature = "local")]
#[tokio::main]
async fn main() -> Result<(), lambda_runtime::Error> {
    function_handler(LambdaEvent::new(serde_json::json!({}), Default::default())).await
}

#[cfg(feature = "default")]
#[tokio::main]
async fn main() -> Result<(), lambda_runtime::Error> {
    lambda_runtime::run(service_fn(function_handler)).await
}

localフラグが設定されているときはローカル実行用にビルドし、何も設定されていないときはLambda実行用にビルドします。

Dockerfile

ChatGPTに質問しながら作成しました。
x86_64-unknown-linux-muslをtargetにするのはChatGPTの提案でしたが、builderのイメージとLambdaのイメージでglibcのバージョンが異なっていたり必要なライブラリが揃っていなかったりしてエラーになると面倒なので、この提案を採用しました。
今回のコードは比較的色々なcrateに依存しているのでどこかしらでエラーになるかもしれない...、と思っていましたが、予想に反して特に問題なくビルドできました。
詳しくはわかりませんが、rust:1.92-bullseyeイメージの中でいい感じに処理してくれているのかもしれません。

FROM rust:1.92-bullseye AS builder

WORKDIR /app
COPY . .
RUN apt-get update && apt-get install -y musl-tools
RUN rustup target add x86_64-unknown-linux-musl
RUN cargo build --release --target x86_64-unknown-linux-musl

FROM public.ecr.aws/lambda/provided:al2023

COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/weather-bot /var/runtime/bootstrap
COPY Data/ /var/runtime/Data/
ENV RUST_LOG=info
CMD ["bootstrap"]

Terraform

botを動作させるために必要なAWSリソースはTerraformで作成します。
コードはterraformディレクトリに格納されています。

SSM

前回からの差分として、Parameter StoreのパラメータをTerraformで作成しています。
Terraformからパラメータを作成する際はとりあえず仮の値をセットしておいて、実際のアクセストークンはAWSコンソールから手動で設定します。

modules/weather_forecast_bot/ssm/main.tf
resource "aws_ssm_parameter" "weather_api_access_token" {
  name = "weather-api-access-token"
  type = "SecureString"

  #You have to set the actual value via AWS management console.
  value = "dummy"

  lifecycle {
    ignore_changes = [
      value
    ]
  }
}

resource "aws_ssm_parameter" "misskey_access_token" {
  name = "misskey-access-token"
  type = "SecureString"

  #You have to set the actual value via AWS management console.
  value = "dummy"

  lifecycle {
    ignore_changes = [
      value
    ]
  }
}

パラメータ(value)はTerraformではなく手動で更新するので、ignore_changesに追加してplan実行時の差分に表示されないようにしています。

IAMロール

今回のLambdaはParameter Storeから値を取得する権限が必要です。

modules/weather_forecast_bot/iam/main.tf
resource "aws_iam_policy" "allow_lambda_access_to_ssm_parameter" {
  name = "${var.service}-allow-lambda-access-to-ssm-parameter-${var.env}"
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect   = "Allow"
        Action   = "ssm:GetParameter"
        Resource = var.ssm_parameter_arns
      }
    ]
  })
}

Lambda

環境変数にアクセストークンを設定しないので、今回はすべての環境変数をTerraformで管理します。

modules/weather_forecast_bot/lambda/main.tf
resource "aws_lambda_function" "main" {
  function_name = "${var.service}-${var.env}"

  role = var.lambda_role_arn

  #Actual image is deployed by GitHub Actions
  package_type = "Image"
  image_uri    = "${var.repository_url}:temp"

  timeout     = var.lambda_config.timeout
  memory_size = var.lambda_config.memory_size

  environment {
    variables = var.lambda_config.environment_variables
  }

  lifecycle {
    ignore_changes = [
      image_uri
    ]
  }
}

Lambda作成時に設定するイメージは仮のもので、実際のイメージはGitHub Actionsでデプロイします。
イメージのURIはGitHub Actionsでデプロイするたびに変わるので、ignore_changesに追加してplan実行時に差分として表示されないようにしています。

仮のイメージについては、ECRリポジトリを作成する際にシェルスクリプトを実行して用意しています。

modules/weather_forecast_bot/ecr/main.tf
resource "aws_ecr_repository" "main" {
  name                 = "lambda/misskey-weather-bot-2"
  image_tag_mutability = "IMMUTABLE"
}

resource "terraform_data" "main" {
  triggers_replace = [
    aws_ecr_repository.main.arn
  ]

  provisioner "local-exec" {
    command = "bash ${path.module}/push_temp_image.sh"
    environment = {
      AWS_REGION     = var.aws.region
      AWS_ACCOUNT_ID = var.aws.account_id
      REPOSITORY_URL = aws_ecr_repository.main.repository_url
    }
  }
}
modules/weather_forecast_bot/ecr/push_temp_image.sh
#!/bin/bash

aws ecr get-login-password --region ${AWS_REGION} | docker login --username AWS --password-stdin ${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com
docker pull hello-world:latest
docker tag hello-world:latest ${REPOSITORY_URL}:temp
docker push ${REPOSITORY_URL}:temp

github_actions

aws_iam_openid_connect_providerthumbprint_listについては、GitHubと連携させる際には不要らしいので、今回のコードでは指定していません。

IAMロールについてはtoken.actions.githubusercontent.com:subの部分が重要で、この設定によってこのIAMロールをAssumeできるGitHubリポジトリを制限できます。

modules/github_actions/main.tf
resource "aws_iam_openid_connect_provider" "github_actions" {
  url            = "https://token.actions.githubusercontent.com"
  client_id_list = ["sts.amazonaws.com"]
}

resource "random_id" "rnd" {
  byte_length = 4
}

resource "aws_iam_role" "github_actions" {
  name = "${var.service}-github-actions-${var.env}-${random_id.rnd.hex}"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = "sts:AssumeRoleWithWebIdentity"
        Principal = {
          Federated = aws_iam_openid_connect_provider.github_actions.arn
        }
        Condition = {
          StringEquals = {
            "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
          }
          StringLike = {
            "token.actions.githubusercontent.com:sub" = "repo:${var.github_info.username}/${var.github_info.repo_name}:*"
          }
        }
      }
    ]
  })
}

resource "aws_iam_policy" "allow_github_actions_access_to_ecr" {
  name = "${var.service}-allow-github-actions-access-to-ecr-${var.env}"
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "ecr:UploadLayerPart",
          "ecr:PutImage",
          "ecr:InitiateLayerUpload",
          "ecr:CompleteLayerUpload",
          "ecr:BatchGetImage",
          "ecr:BatchCheckLayerAvailability"
        ]
        Resource = var.weather_forecast_bot.ecr.main.arn
      },
      {
        Effect   = "Allow"
        Action   = "ecr:GetAuthorizationToken"
        Resource = "*"
      }
    ]
  })
}

resource "aws_iam_policy" "allow_github_actions_access_to_lambda" {
  name = "${var.service}-allow-github-actions-access-to-lambda-${var.env}"
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect   = "Allow"
        Action   = "lambda:UpdateFunctionCode"
        Resource = var.weather_forecast_bot.lambda.main.arn
      }
    ]
  })
}

resource "aws_iam_role_policy_attachment" "allow_github_actions_access_to_ecr" {
  role       = aws_iam_role.github_actions.name
  policy_arn = aws_iam_policy.allow_github_actions_access_to_ecr.arn
}

resource "aws_iam_role_policy_attachment" "allow_github_actions_access_to_lambda" {
  role       = aws_iam_role.github_actions.name
  policy_arn = aws_iam_policy.allow_github_actions_access_to_lambda.arn
}

GitHub Actionsに与える権限はLambdaをデプロイするために必要な最小限のものに制限しています。

GitHub Actions

イメージのビルド、プッシュ、Lambdaへのデプロイを実行します。
共通する処理はreusable workflowにしておいて、それをsandbox環境とprod環境のワークフローから呼び出すという構成になっています。

.github/workflows/deploy-lambda.yml
name: Deploy Lambda function

on:
  workflow_call:
    inputs:
      lambdaFunctionName:
        type: string
        required: true
      ecrRepoName:
        type: string
        required: true
    secrets:
      awsDeploymentRoleArn:
        required: true

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v6
      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v6
        with:
          aws-region: ap-northeast-1
          role-to-assume: ${{secrets.awsDeploymentRoleArn}}
      - name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2
      - name: Set up QEMU
        uses: docker/setup-qemu-action@v4
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v4
      - name: Build and push
        uses: docker/build-push-action@v7
        env:
          REGISTRY: ${{steps.login-ecr.outputs.registry}}
          IMAGE_TAG: ${{github.sha}}
        with:
          platforms: linux/amd64
          context: weather-bot
          push: true
          tags: ${{env.REGISTRY}}/${{inputs.ecrRepoName}}:${{env.IMAGE_TAG}}
          provenance: false
      - name: Update Lambda function
        env:
          REGISTRY: ${{steps.login-ecr.outputs.registry}}
          IMAGE_TAG: ${{github.sha}}
        run: |
          aws lambda update-function-code \
            --function-name ${{inputs.lambdaFunctionName}} \
            --image-uri ${{env.REGISTRY}}/${{inputs.ecrRepoName}}:${{env.IMAGE_TAG}}
.github/workflows/deploy-lambda-prod.yml
name: Deploy Lambda function to prod environment

on:
  push:
    branches:
      - main
    paths:
      - weather-bot/**
      - "!weather-bot/**.md"
      - "!weather-bot/**.png"
  workflow_dispatch:

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    uses: ./.github/workflows/deploy-lambda.yml
    with:
      lambdaFunctionName: misskey-weather-bot-2-prod
      ecrRepoName: lambda/misskey-weather-bot-2
    secrets:
      awsDeploymentRoleArn: ${{secrets.AWS_DEPLOYMENT_ROLE_ARN_PROD}}

感想

GitHub Actionsのログにawsコマンドの実行結果を出力したくないなら、単純にコマンドの実行結果を/dev/nullにリダイレクトすればいいだけでしたが、botのコードを書き直してから気づきました。
まあ、Parameter Storeからアクセストークンを取得する構成の方がベターなので、今回はこれでよしとします。
Lambdaの環境変数にアクセストークンを設定しなくなったことで、環境変数をすべてTerraformのコードで管理できるようになりましたし。

自分はこの記事を書くためにコードをPublicにしていますが、もしもこれを参考にしてMisskeyのbotを作成する物好きな方がいるなら、Privateリポジトリにした方がいい気がします。
IaCのコードやawsコマンドの実行結果を公開する理由は特にないでしょう。
PublicリポジトリだとGitHub Actionsが無料で使えるので、そこは大きなメリットですが...。

前回のような不祥事が起こらない限りは自分のコードはPublicにしておくので、不祥事が起こらないことを祈っていてください。

0
0
0

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
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?