mirror of
https://github.com/mii443/cigarette-counter.git
synced 2025-12-03 11:08:19 +00:00
first commit
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/target
|
||||
3229
Cargo.lock
generated
Normal file
3229
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
14
Cargo.toml
Normal file
14
Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "cigarette-counter"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
poise = "0.6.1"
|
||||
tokio = { version = "1.42.0", features = ["full"] }
|
||||
sqlx = { version = "0.8", features = [ "runtime-tokio", "tls-native-tls", "postgres", "chrono" ] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
native-tls = "0.2"
|
||||
postgres-native-tls = "0.5"
|
||||
anyhow = "1.0.95"
|
||||
13
migrations/20250107040228_cigarette.down.sql
Normal file
13
migrations/20250107040228_cigarette.down.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
DROP VIEW IF EXISTS daily_smoking_summary;
|
||||
|
||||
DROP TRIGGER IF EXISTS update_smoking_logs_updated_at ON smoking_logs;
|
||||
DROP TRIGGER IF EXISTS update_users_updated_at ON users;
|
||||
DROP FUNCTION IF EXISTS update_updated_at_column();
|
||||
|
||||
DROP INDEX IF EXISTS idx_smoking_logs_smoked_at;
|
||||
DROP INDEX IF EXISTS idx_smoking_logs_discord_id;
|
||||
|
||||
DROP TABLE IF EXISTS smoking_logs;
|
||||
DROP TABLE IF EXISTS smoking_types;
|
||||
DROP TABLE IF EXISTS users;
|
||||
|
||||
66
migrations/20250107040228_cigarette.up.sql
Normal file
66
migrations/20250107040228_cigarette.up.sql
Normal file
@@ -0,0 +1,66 @@
|
||||
CREATE TABLE users (
|
||||
discord_id VARCHAR(20) PRIMARY KEY,
|
||||
username VARCHAR(100) NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE smoking_types (
|
||||
id SERIAL PRIMARY KEY,
|
||||
type_name VARCHAR(50) NOT NULL,
|
||||
description TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
INSERT INTO smoking_types (type_name, description)
|
||||
VALUES
|
||||
('traditional', '紙タバコ'),
|
||||
('iqos', 'IQOS');
|
||||
|
||||
CREATE TABLE smoking_logs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
discord_id VARCHAR(20) REFERENCES users(discord_id),
|
||||
smoking_type_id INTEGER REFERENCES smoking_types(id),
|
||||
quantity INTEGER NOT NULL CHECK (quantity > 0),
|
||||
smoked_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_smoking_logs_discord_id ON smoking_logs(discord_id);
|
||||
CREATE INDEX idx_smoking_logs_smoked_at ON smoking_logs(smoked_at);
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
CREATE TRIGGER update_users_updated_at
|
||||
BEFORE UPDATE ON users
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_smoking_logs_updated_at
|
||||
BEFORE UPDATE ON smoking_logs
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE VIEW daily_smoking_summary AS
|
||||
SELECT
|
||||
sl.discord_id,
|
||||
u.username,
|
||||
DATE(sl.smoked_at) as smoke_date,
|
||||
st.type_name,
|
||||
SUM(sl.quantity) as total_quantity
|
||||
FROM smoking_logs sl
|
||||
JOIN users u ON sl.discord_id = u.discord_id
|
||||
JOIN smoking_types st ON sl.smoking_type_id = st.id
|
||||
GROUP BY
|
||||
sl.discord_id,
|
||||
u.username,
|
||||
DATE(sl.smoked_at),
|
||||
st.type_name;
|
||||
|
||||
1
migrations/20250107084522_cigarette.down.sql
Normal file
1
migrations/20250107084522_cigarette.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
DELETE FROM smoking_types WHERE type_name IN ('ploom', 'glo', 'other');
|
||||
5
migrations/20250107084522_cigarette.up.sql
Normal file
5
migrations/20250107084522_cigarette.up.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
INSERT INTO smoking_types (type_name, description)
|
||||
VALUES
|
||||
('ploom', 'プルーム'),
|
||||
('glo', 'グロー'),
|
||||
('other', 'その他');
|
||||
99
src/commands.rs
Normal file
99
src/commands.rs
Normal file
@@ -0,0 +1,99 @@
|
||||
use crate::database::DailySmokingSummary;
|
||||
use crate::{Context, Error};
|
||||
use chrono::Local;
|
||||
use poise::serenity_prelude::{self as serenity, CreateInteractionResponseMessage};
|
||||
use poise::CreateReply;
|
||||
|
||||
async fn create_cigarette_buttons(
|
||||
ctx: &Context<'_>,
|
||||
uuid: &str,
|
||||
) -> Result<Vec<serenity::CreateButton>, Error> {
|
||||
let db = ctx.data().database.lock().await;
|
||||
let cigarette_types = db.get_smoking_types().await?;
|
||||
|
||||
Ok(cigarette_types
|
||||
.into_iter()
|
||||
.map(|cigarette_type| {
|
||||
serenity::CreateButton::new(format!("{}{}", uuid, cigarette_type.id))
|
||||
.style(serenity::ButtonStyle::Primary)
|
||||
.label(cigarette_type.description.unwrap_or_default())
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn format_daily_summary(daily_summary: Vec<DailySmokingSummary>) -> String {
|
||||
daily_summary
|
||||
.into_iter()
|
||||
.map(|summary| {
|
||||
format!(
|
||||
"\n{}: {}本",
|
||||
summary.description,
|
||||
summary.total_quantity.unwrap_or_default()
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn handle_interaction(
|
||||
ctx: &Context<'_>,
|
||||
mci: &serenity::ComponentInteraction,
|
||||
uuid: &str,
|
||||
) -> Result<(), Error> {
|
||||
let db = ctx.data().database.lock().await;
|
||||
let user_id = mci.user.id.get().to_string();
|
||||
let user = db.get_or_create_user(&user_id, &ctx.author().name).await?;
|
||||
|
||||
let cigarette_id = extract_cigarette_id(&mci.data.custom_id, uuid)?;
|
||||
|
||||
db.log_smoking(&user.discord_id, cigarette_id, 1).await?;
|
||||
|
||||
let daily_summary = db
|
||||
.get_daily_summary(&user.discord_id, Local::now().date_naive())
|
||||
.await?;
|
||||
|
||||
let reply_content = format!(
|
||||
"記録しました。\n本日の累計本数{}",
|
||||
format_daily_summary(daily_summary)
|
||||
);
|
||||
|
||||
mci.create_response(
|
||||
ctx,
|
||||
serenity::CreateInteractionResponse::Message(
|
||||
CreateInteractionResponseMessage::new().content(reply_content),
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_cigarette_id(custom_id: &str, uuid: &str) -> Result<i32, Error> {
|
||||
i32::from_str_radix(custom_id.trim_start_matches(uuid), 10)
|
||||
.map_err(|e| Error::from(format!("Failed to parse cigarette ID: {}", e)))
|
||||
}
|
||||
|
||||
#[poise::command(prefix_command)]
|
||||
pub async fn create_cigarette_ui(ctx: Context<'_>) -> Result<(), Error> {
|
||||
let uuid = ctx.id().to_string();
|
||||
|
||||
let buttons = create_cigarette_buttons(&ctx, &uuid).await?;
|
||||
let components = vec![serenity::CreateActionRow::Buttons(buttons)];
|
||||
let reply = CreateReply::default()
|
||||
.content("喫煙カウント")
|
||||
.components(components);
|
||||
|
||||
ctx.send(reply).await?;
|
||||
|
||||
while let Some(mci) = serenity::ComponentInteractionCollector::new(ctx)
|
||||
.channel_id(ctx.channel_id())
|
||||
.filter({
|
||||
let uuid = uuid.clone();
|
||||
move |mci| mci.data.custom_id.starts_with(&uuid)
|
||||
})
|
||||
.await
|
||||
{
|
||||
handle_interaction(&ctx, &mci, &uuid).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
281
src/database.rs
Normal file
281
src/database.rs
Normal file
@@ -0,0 +1,281 @@
|
||||
use chrono::{DateTime, NaiveDate, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{postgres::PgPool, Error};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct User {
|
||||
pub discord_id: String,
|
||||
pub username: String,
|
||||
pub created_at: Option<DateTime<Utc>>,
|
||||
pub updated_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct SmokingType {
|
||||
pub id: i32,
|
||||
pub type_name: String,
|
||||
pub description: Option<String>,
|
||||
pub created_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct SmokingLog {
|
||||
pub id: i32,
|
||||
pub discord_id: String,
|
||||
pub smoking_type_id: i32,
|
||||
pub quantity: i32,
|
||||
pub smoked_at: DateTime<Utc>,
|
||||
pub created_at: Option<DateTime<Utc>>,
|
||||
pub updated_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct DailySmokingSummary {
|
||||
pub discord_id: String,
|
||||
pub username: String,
|
||||
pub smoke_date: NaiveDate,
|
||||
|
||||
pub type_name: String,
|
||||
pub description: String,
|
||||
pub total_quantity: Option<i64>,
|
||||
}
|
||||
|
||||
pub struct Database {
|
||||
pool: Arc<PgPool>,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self {
|
||||
pool: Arc::new(pool),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_user(&self, discord_id: &str, username: &str) -> Result<User, Error> {
|
||||
let user = sqlx::query_as!(
|
||||
User,
|
||||
r#"
|
||||
INSERT INTO users (discord_id, username)
|
||||
VALUES ($1, $2)
|
||||
RETURNING
|
||||
discord_id as "discord_id!",
|
||||
username as "username!",
|
||||
created_at,
|
||||
updated_at
|
||||
"#,
|
||||
discord_id,
|
||||
username
|
||||
)
|
||||
.fetch_one(&*self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
pub async fn get_or_create_user(
|
||||
&self,
|
||||
discord_id: &str,
|
||||
username: &str,
|
||||
) -> Result<User, Error> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
let user = sqlx::query_as!(
|
||||
User,
|
||||
r#"
|
||||
SELECT
|
||||
discord_id,
|
||||
username,
|
||||
created_at as "created_at!",
|
||||
updated_at as "updated_at!"
|
||||
FROM users
|
||||
WHERE discord_id = $1
|
||||
"#,
|
||||
discord_id
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let user = match user {
|
||||
Some(user) => {
|
||||
if user.username != username {
|
||||
sqlx::query_as!(
|
||||
User,
|
||||
r#"
|
||||
UPDATE users
|
||||
SET username = $2, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE discord_id = $1
|
||||
RETURNING
|
||||
discord_id,
|
||||
username,
|
||||
created_at as "created_at!",
|
||||
updated_at as "updated_at!"
|
||||
"#,
|
||||
discord_id,
|
||||
username
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?
|
||||
} else {
|
||||
user
|
||||
}
|
||||
}
|
||||
None => {
|
||||
sqlx::query_as!(
|
||||
User,
|
||||
r#"
|
||||
INSERT INTO users (discord_id, username)
|
||||
VALUES ($1, $2)
|
||||
RETURNING
|
||||
discord_id,
|
||||
username,
|
||||
created_at as "created_at!",
|
||||
updated_at as "updated_at!"
|
||||
"#,
|
||||
discord_id,
|
||||
username
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
pub async fn user_exists(&self, discord_id: &str) -> Result<bool, Error> {
|
||||
let exists = sqlx::query_scalar!(
|
||||
r#"
|
||||
SELECT EXISTS(SELECT 1 FROM users WHERE discord_id = $1) as "exists!"
|
||||
"#,
|
||||
discord_id
|
||||
)
|
||||
.fetch_one(&*self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(exists)
|
||||
}
|
||||
|
||||
pub async fn log_smoking(
|
||||
&self,
|
||||
discord_id: &str,
|
||||
|
||||
smoking_type_id: i32,
|
||||
quantity: i32,
|
||||
) -> Result<SmokingLog, Error> {
|
||||
let log = sqlx::query_as!(
|
||||
SmokingLog,
|
||||
r#"
|
||||
INSERT INTO smoking_logs (discord_id, smoking_type_id, quantity)
|
||||
VALUES ($1, $2, $3)
|
||||
|
||||
RETURNING
|
||||
id as "id!",
|
||||
discord_id as "discord_id!",
|
||||
smoking_type_id as "smoking_type_id!",
|
||||
quantity as "quantity!",
|
||||
smoked_at as "smoked_at!",
|
||||
created_at,
|
||||
updated_at
|
||||
|
||||
"#,
|
||||
discord_id,
|
||||
smoking_type_id,
|
||||
quantity
|
||||
)
|
||||
.fetch_one(&*self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(log)
|
||||
}
|
||||
|
||||
pub async fn get_daily_summary(
|
||||
&self,
|
||||
discord_id: &str,
|
||||
date: NaiveDate,
|
||||
) -> Result<Vec<DailySmokingSummary>, Error> {
|
||||
let summary = sqlx::query_as!(
|
||||
DailySmokingSummary,
|
||||
r#"
|
||||
SELECT
|
||||
sl.discord_id as "discord_id!",
|
||||
u.username as "username!",
|
||||
DATE(sl.smoked_at) as "smoke_date!",
|
||||
st.type_name as "type_name!",
|
||||
st.description as "description!",
|
||||
SUM(sl.quantity) as total_quantity
|
||||
FROM smoking_logs sl
|
||||
JOIN users u ON sl.discord_id = u.discord_id
|
||||
JOIN smoking_types st ON sl.smoking_type_id = st.id
|
||||
WHERE sl.discord_id = $1
|
||||
AND DATE(sl.smoked_at) = $2
|
||||
GROUP BY
|
||||
sl.discord_id,
|
||||
u.username,
|
||||
DATE(sl.smoked_at),
|
||||
st.type_name,
|
||||
st.description
|
||||
"#,
|
||||
discord_id,
|
||||
date
|
||||
)
|
||||
.fetch_all(&*self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
pub async fn get_smoking_type(&self, id: i32) -> Result<SmokingType, Error> {
|
||||
let smoking_type = sqlx::query_as!(
|
||||
SmokingType,
|
||||
r#"
|
||||
SELECT
|
||||
id as "id!",
|
||||
type_name as "type_name!",
|
||||
description,
|
||||
created_at
|
||||
FROM smoking_types
|
||||
WHERE id = $1
|
||||
"#,
|
||||
id
|
||||
)
|
||||
.fetch_one(&*self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(smoking_type)
|
||||
}
|
||||
|
||||
pub async fn get_smoking_types(&self) -> Result<Vec<SmokingType>, Error> {
|
||||
let types = sqlx::query_as!(
|
||||
SmokingType,
|
||||
r#"
|
||||
SELECT
|
||||
id as "id!",
|
||||
type_name as "type_name!",
|
||||
description,
|
||||
created_at
|
||||
FROM smoking_types
|
||||
ORDER BY id
|
||||
"#
|
||||
)
|
||||
.fetch_all(&*self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(types)
|
||||
}
|
||||
|
||||
pub async fn smoking_type_exists(&self, id: i32) -> Result<bool, Error> {
|
||||
let exists = sqlx::query_scalar!(
|
||||
r#"
|
||||
SELECT EXISTS(SELECT 1 FROM smoking_types WHERE id = $1) as "exists!"
|
||||
"#,
|
||||
id
|
||||
)
|
||||
.fetch_one(&*self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(exists)
|
||||
}
|
||||
}
|
||||
56
src/main.rs
Normal file
56
src/main.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
mod commands;
|
||||
mod database;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use commands::create_cigarette_ui;
|
||||
use database::Database;
|
||||
use poise::{
|
||||
serenity_prelude::{self as serenity, futures::lock::Mutex},
|
||||
PrefixFrameworkOptions,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
|
||||
pub struct Data {
|
||||
pub database: Arc<Mutex<Database>>,
|
||||
}
|
||||
|
||||
pub type Error = Box<dyn std::error::Error + Send + Sync>;
|
||||
pub type Context<'a> = poise::Context<'a, Data, Error>;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let token = std::env::var("BOT_TOKEN").expect("missing BOT_TOKEN");
|
||||
let intents = serenity::GatewayIntents::non_privileged() | serenity::GatewayIntents::all();
|
||||
|
||||
let pool =
|
||||
PgPool::connect(&std::env::var("DATABASE_URL").expect("missing DATABASE_URL")).await?;
|
||||
let db = Database::new(pool);
|
||||
|
||||
let framework = poise::Framework::builder()
|
||||
.options(poise::FrameworkOptions {
|
||||
commands: vec![create_cigarette_ui()],
|
||||
prefix_options: PrefixFrameworkOptions {
|
||||
prefix: Some(String::from("c:")),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.setup(|_ctx, _ready, _framework| {
|
||||
Box::pin(async move {
|
||||
Ok(Data {
|
||||
database: Arc::new(Mutex::new(db)),
|
||||
})
|
||||
})
|
||||
})
|
||||
.build();
|
||||
|
||||
let client = serenity::ClientBuilder::new(token, intents)
|
||||
.framework(framework)
|
||||
.await;
|
||||
|
||||
client.unwrap().start().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user