diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..99a616d --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[target.x86_64-unknown-linux-gnu] +rustflags = ["-C", "linker=clang", "-C", "link-arg=-fuse-ld=lld"] diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..72e71a9 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "rustree"] + path = rustree + url = git@git.loyso.art:frx/rustree.git diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..535ca96 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,20 @@ +[workspace] +members = [ + "alterego", + "rustree", +] + +[workspace.dependencies] +anyhow = "1.0.69" +futures = "0.3.26" +log = "0.4.17" +reqwest = "0.11.14" +serde = { version = "1.0.152", features = ["derive"]} +serde_json = "1.0.93" +tokio = { version = "1.25.0", features = ["full"]} +chrono = "0.4.23" +hex = "0.4.3" +hmac = "0.12.1" +percent-encoding = "2.2.0" +sha2 = "0.10.6" +urlencoding = "2.1.2" diff --git a/alterego/.DS_Store b/alterego/.DS_Store new file mode 100644 index 0000000..e37c505 Binary files /dev/null and b/alterego/.DS_Store differ diff --git a/alterego/.drone.jsonnet b/alterego/.drone.jsonnet new file mode 100644 index 0000000..ccecdba --- /dev/null +++ b/alterego/.drone.jsonnet @@ -0,0 +1,93 @@ +local image = std.extVar("image"); +local app_name = std.extVar("app_name"); +local target_arch = std.extVar("target_arch"); + +local flags = " --release --target=" + target_arch; + +local volume(name, path) = { + name: name, + path: path, +}; + +local volumes = [ + volume("cargo", "/usr/local/cargo"), + volume("target", "/cache/target"), + volume("rustup", "/usr/local/rustup"), +]; + +local step(name, depends=[], commands=[], env={}) = { + name: name, + volumes: volumes, + image: image, + depends_on: depends, + commands: commands, + environment: env + { + CARGO_TARGET_DIR: "/cache/target", + APP_NAME: app_name, + }, +}; + +local temp_volume(name) = { + name: name, + temp: {}, +}; + +local host_volume(name, path) = { + name: name, + host: { + path: path, + }, +}; + +{ + kind: "pipeline", + type: "docker", + name: "default", + platform: { + "os": "linux", + "arch": "arm", + }, + + steps: [ + step( + "validate", + commands=["cargo test" + flags], + ), + step( + "test", + depends=["validate"], + commands=["cargo test" + flags], + ), + step( + "build", + depends=["test"], + commands=["cargo build" + flags], + env={ + GIT_REVISION: "${DRONE_COMMIT:0:8}", + GIT_BRANCH: "${DRONE_COMMIT_BRANCH}", + }, + ), + step( + "deploy", + depends=["build"], + commands=["sh scripts/deploy.sh"], + env={ + TARGET_DIR: "/cache/target/" + target_arch + "/release", + SSH_PRIVATE_KEY: {"from_secret": "ssh_pk_base64"}, + SSH_USER: {"from_secret": "SSH_USER"}, + }, + ), + ], + + volumes: [ + temp_volume("target"), + host_volume("cargo", "/home/pi/.cargo"), + host_volume("rustup", "/home/pi/.rustup"), + ], + + // BUG: thid does not add. + // environment: { + // CARGO_TARGET_DIR: "/cache/target/", + // APP_NAME: "deploytest", + // } +} diff --git a/alterego/.drone.yml b/alterego/.drone.yml new file mode 100644 index 0000000..a6d9694 --- /dev/null +++ b/alterego/.drone.yml @@ -0,0 +1,98 @@ +--- +kind: pipeline +type: docker +name: default + +platform: + os: linux + arch: arm + +steps: +- name: validate + image: rust:1.49 + commands: + - cargo test --release --target=armv7-unknown-linux-gnueabihf + environment: + APP_NAME: altherego + CARGO_TARGET_DIR: /cache/target + volumes: + - name: cargo + path: /usr/local/cargo + - name: target + path: /cache/target + - name: rustup + path: /usr/local/rustup + +- name: test + image: rust:1.49 + commands: + - cargo test --release --target=armv7-unknown-linux-gnueabihf + environment: + APP_NAME: altherego + CARGO_TARGET_DIR: /cache/target + volumes: + - name: cargo + path: /usr/local/cargo + - name: target + path: /cache/target + - name: rustup + path: /usr/local/rustup + depends_on: + - validate + +- name: build + image: rust:1.49 + commands: + - cargo build --release --target=armv7-unknown-linux-gnueabihf + environment: + APP_NAME: altherego + CARGO_TARGET_DIR: /cache/target + GIT_BRANCH: ${DRONE_COMMIT_BRANCH} + GIT_REVISION: ${DRONE_COMMIT:0:8} + volumes: + - name: cargo + path: /usr/local/cargo + - name: target + path: /cache/target + - name: rustup + path: /usr/local/rustup + depends_on: + - test + +- name: deploy + image: rust:1.49 + commands: + - sh scripts/deploy.sh + environment: + APP_NAME: altherego + CARGO_TARGET_DIR: /cache/target + SSH_PRIVATE_KEY: + from_secret: ssh_pk_base64 + SSH_USER: + from_secret: SSH_USER + TARGET_DIR: /cache/target/armv7-unknown-linux-gnueabihf/release + volumes: + - name: cargo + path: /usr/local/cargo + - name: target + path: /cache/target + - name: rustup + path: /usr/local/rustup + depends_on: + - build + +volumes: +- name: target + temp: {} +- name: cargo + host: + path: /home/pi/.cargo +- name: rustup + host: + path: /home/pi/.rustup + +--- +kind: signature +hmac: a942d89af2c38916d55ebe377709febf08145ce8e08a2b585dda8a1c251eaca0 + +... diff --git a/alterego/Cargo.toml b/alterego/Cargo.toml new file mode 100644 index 0000000..faa1ea5 --- /dev/null +++ b/alterego/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "alterego" +version = "0.1.0" +authors = ["Aleksandr Trushkin "] +edition = "2018" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +anyhow = {workspace = true} +futures = {workspace = true} +log = {workspace = true} +reqwest = {workspace = true} +serde = {workspace = true, features = ["derive"]} +serde_json = {workspace = true} +tokio = {workspace = true} diff --git a/alterego/build.rs b/alterego/build.rs new file mode 100644 index 0000000..9b79ad7 --- /dev/null +++ b/alterego/build.rs @@ -0,0 +1,41 @@ +use std::env; + +fn main() { + let rev = get_value_from_env("GIT_VERSION") + .or_else(|| get_value_from_command("git", &["rev-parse", "--short", "HEAD"])) + .unwrap_or_else(|| "unknown".to_owned()); + + let branch = get_value_from_env("GIT_BRANCH") + .or_else(|| get_value_from_command("git", &["rev-parse", "--abbrev-ref", "HEAD"])) + .unwrap_or_else(|| "unknown".to_owned()); + + println!("cargo:rustc-env=GIT_REVISION={}", rev); + println!("cargo:rustc-env=GIT_BRANCH={}", branch); + println!("cargo:rerun-if-env-changed=GIT_REVISION"); +} + +fn get_value_from_env(key: &str) -> Option { + env::var(key).map_or_else(|_| None, Some) +} + +fn get_value_from_command, S: AsRef>( + cmd: &str, + args: I, +) -> Option { + std::process::Command::new(cmd) + .args(args) + .output() + .map_or_else( + |_| None, + |out| { + if !out.status.success() { + return None; + } + + match std::str::from_utf8(&out.stdout) { + Ok(value) => Some(value.to_owned()), + Err(_) => None, + } + }, + ) +} diff --git a/alterego/makefile b/alterego/makefile new file mode 100644 index 0000000..f9fa345 --- /dev/null +++ b/alterego/makefile @@ -0,0 +1,40 @@ +export DOCKER_BUILDKIT=1 + +DOCKERFLAGS:=-it --rm \ + -v "${PWD}":"/app" \ + --workdir "/app" \ + -e "PWD=/app" + +DOCKERIMG:="rust-build-env:V1" + +APP_NAME:=altherego +IMAGE:=rust:1.49 +TARGET_ARCH:=armv7-unknown-linux-gnueabihf + +image: + docker build -t rust-build-env:V1 . +.PHONY: image + +ARM_PREFIX:=CARGO_TARGET_ARM_UNKNOWN_LINUX_MUSLEABIHF_LINKER=arm-linux-gnueabihf-ld \ + REALGCC=arm-linux-gnueabihf-gcc-8 \ + TARGET_CC=musl-gcc + +build_debug_arm: + ${ARM_PREFIX} cargo build --target=armv7-unknown-linux-musleabihf +.PHONY: build_debug_arm + +build_release_arm: + docker run ${DOCKERFLAGS} ${DOCKERIMG} /bin/sh -c 'cargo build --release --target=armv7-unknown-linux-gnueabihf' +.PHONY: build_release_arm + +docker_build_release_arm: + docker run ${DOCKERFLAGS} ${DOCKERIMG} make build_release_arm + +dronefile: + drone jsonnet \ + --format \ + -V app_name=${APP_NAME} \ + -V image=${IMAGE} \ + -V target_arch=${TARGET_ARCH} + drone sign frx/altherego --save +.PHONY: dronefile diff --git a/alterego/src/lib.rs b/alterego/src/lib.rs new file mode 100644 index 0000000..e894b90 --- /dev/null +++ b/alterego/src/lib.rs @@ -0,0 +1 @@ +pub mod telegram; diff --git a/alterego/src/main.rs b/alterego/src/main.rs new file mode 100644 index 0000000..bd8abe8 --- /dev/null +++ b/alterego/src/main.rs @@ -0,0 +1,70 @@ +use alterego::telegram; + +use tokio::runtime; +const BOT_TOKEN: &str = "170515067:AAElDJ8Sq_oIqo9WaL4DKvUr13nSEIdHCYs"; + +fn main() -> anyhow::Result<()> { + println!("Hello, world!"); + + let rt = runtime::Builder::new_current_thread() + .enable_io() + .enable_time() + .build() + .expect("making runtime"); + + rt.block_on(app())?; + + Ok(()) +} + +use futures::StreamExt; + +async fn app() -> anyhow::Result<()> { + let bot = telegram::bot::Client::new(BOT_TOKEN.to_owned()); + + let mut stream = bot.updates_stream(); + while let Some(update) = stream.next().await { + let update = update?; + + println!("{:?}", update); + + handle_update(update); + } + + Ok(()) +} + +use log::{debug, trace, warn}; +use telegram::types::UpdateKind; + +fn handle_update(update: telegram::types::Update) { + match update.kind { + UpdateKind::Message(msg) => { + let text = msg.text.unwrap_or_default(); + + if !text.starts_with('/') { + trace!("it's not a command, skipping"); + return; + } + + match text.as_str() { + "/help" => { + println!("help command called"); + } + "/temp" => { + println!("temp command called"); + } + "/versionrequest" => {} + other => { + println!("unknown command {}", other); + } + } + } + UpdateKind::EditedMessage(msg) => { + debug!("edited message: {:?}", msg); + } + UpdateKind::Undefined => { + warn!("message udentified"); + } + } +} diff --git a/alterego/src/telegram/bot.rs b/alterego/src/telegram/bot.rs new file mode 100644 index 0000000..0550bf0 --- /dev/null +++ b/alterego/src/telegram/bot.rs @@ -0,0 +1,184 @@ +use super::types::*; + +use std::{ + cmp::max, + collections::VecDeque, + future::Future, + pin::Pin, + sync::atomic::{AtomicI32, Ordering}, + task::{Context, Poll}, +}; + +use log::{debug, trace}; +use reqwest; + +const TELEGRAM_URL: &str = "https://api.telegram.org"; +const HTTP_CLIENT: &str = "alterego-http-client/1.0"; + +#[derive(Debug)] +pub enum BotError { + API(String), +} + +impl std::fmt::Display for BotError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let text = match self { + BotError::API(msg) => format!("api error: {}", msg), + }; + + write!(f, "{}", text) + } +} + +impl std::error::Error for BotError {} + +#[derive(Debug, Clone)] +/// Client provides API for communicating with Telegram Bot API. +pub struct Client { + pub token: String, + pub client: reqwest::Client, +} + +impl Client { + pub fn new(token: String) -> Self { + let client = reqwest::ClientBuilder::default() + .connect_timeout(std::time::Duration::from_secs(5)) + .https_only(true) + .user_agent(HTTP_CLIENT) + .build() + .expect("building http client"); + + Self { token, client } + } + + fn make_url(&self, method: &str) -> String { + format!("{}/bot{}/{}", TELEGRAM_URL, self.token, method) + } + + fn fetch_updates( + &self, + offset: i32, + ) -> impl Future>>> { + let client = self.clone(); + async move { client.get_updates(offset).await } + } + + pub async fn get_updates(&self, offset: i32) -> anyhow::Result>> { + const METHOD: &str = "getUpdates"; + + trace!("getting updates"); + + let url = { + let mut url = reqwest::Url::parse(&self.make_url(METHOD))?; + url.set_query(Some(format!("offset={}", offset).as_str())); + url + }; + + debug!("requesting: {}", url.as_str()); + let body = self.client.get(url).send().await?.bytes().await?; + + let response: Response = serde_json::from_reader(&body[..])?; + if response.is_err() { + debug!("response finished with error"); + return Err(anyhow::anyhow!(BotError::API( + response.description.unwrap() + ))); + }; + + Ok(response.result) + } + + pub fn updates_stream(&self) -> ClientUpdateStream { + ClientUpdateStream::new(self) + } + + pub async fn reply(&self) -> anyhow::Result<()> { + todo!("soon") + } +} + +type PinnedRequest = Pin>>> + Send>>; + +pub struct ClientUpdateStream { + client: Client, + next_id: AtomicI32, + buffer: VecDeque, + pinned_request: Option, +} + +impl ClientUpdateStream { + pub fn new(client: &Client) -> Self { + Self { + client: client.clone(), + next_id: AtomicI32::new(0), + buffer: VecDeque::new(), + pinned_request: None, + } + } +} + +impl futures::Stream for ClientUpdateStream { + type Item = anyhow::Result; + + /// poll_next fetches updates from client and appends it to + /// buffer. + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let myself = self.get_mut(); + + let mut current_id = myself.next_id.load(Ordering::SeqCst); + + if let Some(value) = myself.buffer.pop_front() { + return Poll::Ready(Some(Ok(value))); + }; + + // Check the current state of myself. + // In case request is being processed, return Pending; + // In case request is ready, insert updates and return Ok(true); + // In case there is no request or updates are empty, return Ok(false); + // Ok(state), where states defines whether new request should be + // created or not. + let result = match myself.pinned_request { + None => Ok(false), + Some(ref mut request) => { + let request = request.as_mut(); + let polled_request = request.poll(cx); + + match polled_request { + Poll::Pending => return Poll::Pending, + Poll::Ready(Ok(None)) => Ok(false), + Poll::Ready(Ok(Some(ref updates))) if updates.is_empty() => Ok(false), + Poll::Ready(Ok(Some(updates))) => { + for update in updates { + current_id = max(current_id, update.id + 1); + myself.buffer.push_back(update); + } + + Ok(true) + } + Poll::Ready(Err(err)) => Err(err), + } + } + }; + + myself.next_id.store(current_id, Ordering::SeqCst); + + match result { + Ok(true) => { + myself.pinned_request = None; + Pin::new(myself).poll_next(cx) + } + Ok(false) => { + let next_request = myself.client.fetch_updates(current_id); + myself.pinned_request = Some(Box::pin(next_request)); + + Pin::new(myself).poll_next(cx) + } + Err(err) => { + let next_request = myself.client.fetch_updates(current_id); + myself.pinned_request = Some(Box::pin(next_request)); + + Poll::Ready(Some(Err(err))) + } + } + } +} diff --git a/alterego/src/telegram/mod.rs b/alterego/src/telegram/mod.rs new file mode 100644 index 0000000..33547fc --- /dev/null +++ b/alterego/src/telegram/mod.rs @@ -0,0 +1,2 @@ +pub mod bot; +pub mod types; diff --git a/alterego/src/telegram/types.rs b/alterego/src/telegram/types.rs new file mode 100644 index 0000000..77d9fb0 --- /dev/null +++ b/alterego/src/telegram/types.rs @@ -0,0 +1,63 @@ +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub struct Response { + pub ok: bool, + pub error_code: Option, + pub description: Option, + pub result: Option>, +} + +impl Response { + pub fn is_err(&self) -> bool { + !self.ok + } +} + +#[derive(Debug, Deserialize)] +pub struct Update { + #[serde(rename = "update_id")] + pub id: i32, + + #[serde(flatten)] + pub kind: UpdateKind, +} + +#[derive(Debug, Deserialize)] +pub enum UpdateKind { + #[serde(rename = "message")] + Message(Message), + + #[serde(rename = "edited_message")] + EditedMessage(Message), + + Undefined, +} + +#[derive(Debug, Deserialize)] +pub struct Message { + pub message_id: i32, + pub from: Option, + pub sender_chat: Option, + pub date: i64, + pub chat: Chat, + pub text: Option, +} + +#[derive(Debug, Deserialize)] +pub struct User { + pub id: i32, + pub is_bot: bool, + pub first_name: String, + pub last_name: Option, + pub username: Option, +} + +#[derive(Debug, Deserialize)] +pub struct Chat { + pub id: i32, + #[serde(alias = "type")] + pub chat_type: String, + pub title: Option, + pub username: Option, +} diff --git a/rustree b/rustree new file mode 160000 index 0000000..12e86d7 --- /dev/null +++ b/rustree @@ -0,0 +1 @@ +Subproject commit 12e86d70799633acf3e44e036fb6da19f41f3a2a