├── tools ├── __init__.py ├── pylintrc └── qyapi_wx_send_robot.py ├── debug_run.bat ├── src ├── handles │ ├── mod.rs │ ├── default.rs │ └── robot.rs ├── wxwork_robot │ ├── error.rs │ ├── mod.rs │ ├── base64.rs │ ├── command_runtime.rs │ └── command.rs ├── main.rs ├── logger │ └── mod.rs └── app.rs ├── enc.in ├── debug_run.ps1 ├── .gitattributes ├── input.txt ├── .gitignore ├── etc ├── firewalld │ └── wxwork_robotd.xml ├── systemd │ └── wxwork_robotd.service ├── conf.json ├── init.d │ └── wxwork_robotd └── systemv │ └── wxwork_robotd ├── .flake8 ├── .github └── workflows │ ├── stale.yml │ ├── build-on-macos.yml │ ├── build-on-windows.yml │ └── build-on-linux.yml ├── Dockerfile ├── ci ├── script.sh ├── before_deploy.ps1 ├── before_deploy.sh └── install.sh ├── LICENSE-MIT ├── Cross.toml ├── .pylintrc ├── CHANGELOG.md ├── Cargo.toml ├── LICENSE └── README.md /tools/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /debug_run.bat: -------------------------------------------------------------------------------- 1 | @powershell -File debug_run.ps1 -------------------------------------------------------------------------------- /src/handles/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod default; 2 | pub mod robot; 3 | -------------------------------------------------------------------------------- /enc.in: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/owent/wxwork_robotd/HEAD/enc.in -------------------------------------------------------------------------------- /debug_run.ps1: -------------------------------------------------------------------------------- 1 | $ENV:RUST_BACKTRACE = 1 2 | 3 | & cargo run -- -c .\etc\conf.json -d -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.sh eol=lf 2 | *.py eol=lf 3 | *.rs eol=crlf 4 | 5 | /etc/init.d/wxwork_robotd eol=lf -------------------------------------------------------------------------------- /input.txt: -------------------------------------------------------------------------------- 1 | ! TRAVIS input file 2 | ! Created with TRAVIS version compiled at Dec 20 2018 01:24:43 3 | ! Input file written at Thu Dec 20 09:42:01 2018. 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.vscode/settings.json 2 | .idea 3 | 4 | /target 5 | **/*.rs.bk 6 | *.log.* 7 | *.dll 8 | *.pyc 9 | __pycache__ 10 | 11 | wxwork_robotd.pid 12 | /wxwork_robotd-* 13 | -------------------------------------------------------------------------------- /etc/firewalld/wxwork_robotd.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | wxwork_robotd 4 | wxwork_robotd 5 | 6 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = 3 | ;W503 line break before binary operator 4 | W503, 5 | ;E203 whitespace before ‘:’ 6 | E203, 7 | 8 | ; exclude file 9 | exclude = 10 | .tox, 11 | .git, 12 | __pycache__, 13 | build, 14 | dist, 15 | *.pyc, 16 | *.egg-info, 17 | .cache, 18 | .eggs 19 | 20 | max-line-length = 120 -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: "Mark and close stale issues" 2 | on: 3 | schedule: 4 | - cron: "30 2 * * *" 5 | 6 | jobs: 7 | stale: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/stale@v6 11 | with: 12 | stale-issue-message: "This issue was marked as stale due to lack of activity." 13 | days-before-issue-stale: 90 14 | exempt-issue-labels: "do-not-stale" 15 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM docker.io/alpine:latest 2 | 3 | LABEL maintainer "OWenT " 4 | 5 | RUN mkdir -p /opt/wxwork_robotd 6 | 7 | ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/opt/wxwork_robotd/bin 8 | 9 | COPY "./bin" "/opt/wxwork_robotd/bin" 10 | COPY "./etc" "/opt/wxwork_robotd/etc" 11 | COPY "./tools" "/opt/wxwork_robotd/tools" 12 | 13 | CMD ["wxwork_robotd", "-c", "/opt/wxwork_robotd/etc/conf.json"] -------------------------------------------------------------------------------- /ci/script.sh: -------------------------------------------------------------------------------- 1 | # This script takes care of testing your crate 2 | 3 | set -ex 4 | 5 | # TODO This is the "test phase", tweak it as you see fit 6 | main() { 7 | cross build --target $TARGET 8 | cross build --target $TARGET --release 9 | 10 | if [ ! -z $DISABLE_TESTS ]; then 11 | return 12 | fi 13 | 14 | cross test --target $TARGET 15 | cross test --target $TARGET --release 16 | 17 | cross run --target $TARGET 18 | cross run --target $TARGET --release 19 | } 20 | 21 | # we don't run the "test phase" when doing deploys 22 | if [ -z $TRAVIS_TAG ]; then 23 | main 24 | fi 25 | -------------------------------------------------------------------------------- /ci/before_deploy.ps1: -------------------------------------------------------------------------------- 1 | # This script takes care of packaging the build artifacts that will go in the 2 | # release zipfile 3 | 4 | $SRC_DIR = $PWD.Path 5 | $STAGE = [System.Guid]::NewGuid().ToString() 6 | 7 | Set-Location $ENV:Temp 8 | New-Item -Type Directory -Name $STAGE 9 | Set-Location $STAGE 10 | 11 | $ZIP = "$SRC_DIR\$($Env:CRATE_NAME)-$($Env:APPVEYOR_REPO_TAG_NAME)-$($Env:TARGET).zip" 12 | 13 | # TODO Update this to package the right artifacts 14 | Copy-Item "$SRC_DIR\target\$($Env:TARGET)\release\hello.exe" '.\' 15 | 16 | 7z a "$ZIP" * 17 | 18 | Push-AppveyorArtifact "$ZIP" 19 | 20 | Remove-Item *.* -Force 21 | Set-Location .. 22 | Remove-Item $STAGE 23 | Set-Location $SRC_DIR 24 | -------------------------------------------------------------------------------- /src/handles/default.rs: -------------------------------------------------------------------------------- 1 | use actix_web::{HttpRequest, HttpResponse}; 2 | 3 | use super::super::app::AppEnvironment; 4 | 5 | pub async fn dispatch_default_index(app: AppEnvironment, _: HttpRequest) -> HttpResponse { 6 | let output = format!( 7 | " 8 | 9 | 10 | 14 | {} 15 | {}", 16 | app.appname, 17 | app.html_info() 18 | ); 19 | 20 | HttpResponse::Forbidden() 21 | .content_type("text/html") 22 | .body(output) 23 | } 24 | -------------------------------------------------------------------------------- /ci/before_deploy.sh: -------------------------------------------------------------------------------- 1 | # This script takes care of building your crate and packaging it for release 2 | 3 | set -ex 4 | 5 | main() { 6 | local src=$(pwd) \ 7 | stage= 8 | 9 | case $TRAVIS_OS_NAME in 10 | linux) 11 | stage=$(mktemp -d) 12 | ;; 13 | osx) 14 | stage=$(mktemp -d -t tmp) 15 | ;; 16 | esac 17 | 18 | test -f Cargo.lock || cargo generate-lockfile 19 | 20 | # TODO Update this to build the artifacts that matter to you 21 | cross rustc --bin hello --target $TARGET --release -- -C lto 22 | 23 | # TODO Update this to package the right artifacts 24 | cp target/$TARGET/release/hello $stage/ 25 | 26 | cd $stage 27 | tar czf $src/$CRATE_NAME-$TRAVIS_TAG-$TARGET.tar.gz * 28 | cd $src 29 | 30 | rm -rf $stage 31 | } 32 | 33 | main 34 | -------------------------------------------------------------------------------- /etc/systemd/wxwork_robotd.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=WXWorkRobotd Service 3 | After=network.target 4 | Wants=network.target 5 | 6 | [Service] 7 | # This service runs as root. You may consider to run it as another user for security concerns. 8 | # By uncommenting the following two lines, this service will run as user wxwork_robot/wxwork_robot. 9 | # User=wxwork_robot 10 | # Group=wxwork_robot 11 | Type=simple 12 | Environment="WXWORK_ROBOTD_HOME=/usr/local/wxwork_robotd" "WXWORK_ROBOTD_ETC=/usr/local/wxwork_robotd/etc/conf.json" 13 | PIDFile=$WXWORK_ROBOTD_HOME/run/wxwork_robotd.pid 14 | ExecStart=$WXWORK_ROBOTD_HOME/bin/wxwork_robotd --pid-file "$WXWORK_ROBOTD_HOME/run/wxwork_robotd.pid" -c "$WXWORK_ROBOTD_ETC" --log "$WXWORK_ROBOTD_HOME/log/wxwork_robotd.log" 15 | Restart=on-failure 16 | # Don't restart in the case of configuration error 17 | RestartPreventExitStatus=23 18 | 19 | [Install] 20 | WantedBy=multi-user.target 21 | -------------------------------------------------------------------------------- /src/wxwork_robot/error.rs: -------------------------------------------------------------------------------- 1 | use super::base64; 2 | 3 | #[derive(Debug)] 4 | pub enum Error { 5 | StringErr(String), 6 | ActixWebErr(actix_web::Error), 7 | Base64Err(base64::DecodeError), 8 | } 9 | 10 | impl From for actix_web::Error { 11 | fn from(e: Error) -> Self { 12 | match e { 13 | Error::ActixWebErr(x) => x, 14 | Error::StringErr(x) => actix_web::error::ErrorForbidden(x), 15 | Error::Base64Err(x) => actix_web::error::ErrorForbidden(format!("{:?}", x)), 16 | } 17 | } 18 | } 19 | 20 | // impl Into for Error { 21 | // fn into(self) -> actix_web::Error { 22 | // match self { 23 | // Error::ActixWebErr(x) => x, 24 | // Error::StringErr(x) => actix_web::error::ErrorForbidden(x), 25 | // Error::ActixWebErr(x) => actix_web::error::ErrorForbidden(format!("{:?}", x)), 26 | // Error::Base64Err(x) => actix_web::error::ErrorForbidden(format!("{:?}", x)), 27 | // } 28 | // } 29 | // } 30 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024 owent 2 | 3 | Permission is hereby granted, free of charge, to any 4 | person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the 6 | Software without restriction, including without 7 | limitation the rights to use, copy, modify, merge, 8 | publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software 10 | is furnished to do so, subject to the following 11 | conditions: 12 | 13 | The above copyright notice and this permission notice 14 | shall be included in all copies or substantial portions 15 | of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 18 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 19 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 20 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 21 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 22 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 23 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 24 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 25 | DEALINGS IN THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /Cross.toml: -------------------------------------------------------------------------------- 1 | [build.env] 2 | passthrough = [ 3 | "RUST_BACKTRACE", 4 | "RUST_LOG", 5 | "TRAVIS", 6 | "PKG_CONFIG_ALL_STATIC", 7 | ] 8 | 9 | # [target.i686-apple-darwin] 10 | # xargo = true 11 | # 12 | # [target.mips-unknown-linux-gnu] 13 | # xargo = true 14 | # 15 | # [target.mipsel-unknown-linux-gnu] 16 | # xargo = true 17 | # 18 | # [target.mips64-unknown-linux-gnuabi64] 19 | # xargo = true 20 | # 21 | # [target.mips64el-unknown-linux-gnuabi64] 22 | # xargo = true 23 | # 24 | # [target.powerpc-unknown-linux-gnu] 25 | # xargo = true 26 | # 27 | # [target.powerpc64-unknown-linux-gnu] 28 | # xargo = true 29 | # 30 | # [target.powerpc64le-unknown-linux-gnu] 31 | # xargo = true 32 | # 33 | # [target.aarch64-unknown-linux-musl] 34 | # xargo = true 35 | # 36 | # [target.arm-unknown-linux-musleabihf] 37 | # xargo = true 38 | # 39 | # [target.arm-unknown-linux-musleabi] 40 | # xargo = true 41 | # 42 | # [target.armv7-unknown-linux-musleabihf] 43 | # xargo = true 44 | # 45 | # [target.mips-unknown-linux-musl] 46 | # xargo = true 47 | # 48 | # [target.mipsel-unknown-linux-musl] 49 | # xargo = true 50 | # 51 | # [target.aarch64-linux-android] 52 | # xargo = true 53 | # rustflags = ["-A", "E0308"] -------------------------------------------------------------------------------- /ci/install.sh: -------------------------------------------------------------------------------- 1 | set -ex 2 | 3 | main() { 4 | local target= 5 | if [ $TRAVIS_OS_NAME = linux ]; then 6 | target=x86_64-unknown-linux-musl 7 | sort=sort 8 | else 9 | target=x86_64-apple-darwin 10 | sort=gsort # for `sort --sort-version`, from brew's coreutils. 11 | fi 12 | 13 | # Builds for iOS are done on OSX, but require the specific target to be 14 | # installed. 15 | case $TARGET in 16 | aarch64-apple-ios) 17 | rustup target install aarch64-apple-ios 18 | ;; 19 | armv7-apple-ios) 20 | rustup target install armv7-apple-ios 21 | ;; 22 | armv7s-apple-ios) 23 | rustup target install armv7s-apple-ios 24 | ;; 25 | i386-apple-ios) 26 | rustup target install i386-apple-ios 27 | ;; 28 | x86_64-apple-ios) 29 | rustup target install x86_64-apple-ios 30 | ;; 31 | esac 32 | 33 | # This fetches latest stable release 34 | local tag=$(git ls-remote --tags --refs --exit-code https://github.com/japaric/cross \ 35 | | cut -d/ -f3 \ 36 | | grep -E '^v[0.1.0-9.]+$' \ 37 | | $sort --version-sort \ 38 | | tail -n1) 39 | curl -LSfs https://japaric.github.io/trust/install.sh | \ 40 | sh -s -- \ 41 | --force \ 42 | --git japaric/cross \ 43 | --tag $tag \ 44 | --target $target 45 | } 46 | 47 | main -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | # Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the 3 | # number of processors available to use. 4 | jobs=1 5 | 6 | 7 | [MESSAGES CONTROL] 8 | 9 | # Disable the message, report, category or checker with the given id(s). 10 | disable=all 11 | 12 | # Enable the message, report, category or checker with the given id(s). 13 | enable=c-extension-no-member, 14 | bad-indentation, 15 | bare-except, 16 | broad-except, 17 | dangerous-default-value, 18 | function-redefined, 19 | len-as-condition, 20 | line-too-long, 21 | misplaced-future, 22 | missing-final-newline, 23 | mixed-line-endings, 24 | multiple-imports, 25 | multiple-statements, 26 | singleton-comparison, 27 | trailing-comma-tuple, 28 | trailing-newlines, 29 | trailing-whitespace, 30 | unexpected-line-ending-format, 31 | unused-import, 32 | unused-variable, 33 | wildcard-import, 34 | wrong-import-order 35 | 36 | 37 | [FORMAT] 38 | 39 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 40 | expected-line-ending-format=LF 41 | 42 | # Regexp for a line that is allowed to be longer than the limit. 43 | ignore-long-lines=^\s*(# )??$ 44 | 45 | # Maximum number of characters on a single line. 46 | max-line-length=120 47 | 48 | # Maximum number of lines in a module. 49 | max-module-lines=2000 50 | 51 | 52 | [EXCEPTIONS] 53 | 54 | # Exceptions that will emit a warning when being caught. Defaults to 55 | # "BaseException, Exception". 56 | overgeneral-exceptions=BaseException, 57 | Exception -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | CHANGELOG 2 | ============ 3 | 4 | v0.10.1 5 | ---------- 6 | 7 | 1. 修复回包的espace问题 8 | 2. 增加 `static_root` 选项,以便用于设置静态文件 9 | 10 | v0.10.0 11 | ---------- 12 | 13 | 1. 升级 clap 到 ^4.2.0 14 | 2. 升级 awc 到 ^3.1.0 15 | 3. 升级 rustls 到 >0.21.0 16 | 4. 升级 quick-xml 到 >=0.28.0 17 | 5. 升级 block-modes 到 >=0.9.0 18 | 6. 升级 aes 到 >=0.8.2 19 | 7. 升级 cipher 到 >=0.4.0 20 | 21 | v0.9.1 22 | ---------- 23 | 24 | 1. 升级 actix-web 到 4.0.0-beta.15 25 | 2. 升级 tokio 到 1.15.0 26 | 3. 使用 awc 发起HTTP请求 27 | 28 | v0.9.0 29 | ---------- 30 | 31 | 1. 响应环境变量增加 `WXWORK_ROBOT_POST_ID` 32 | 2. 命令行发送工具增加发送原始json和模板类消息(灰度功能)的支持 33 | 34 | v0.8.0 35 | ---------- 36 | 37 | 1. 使用新的Async/Await机制重构代码 38 | 2. 移除openssl依赖,现在使用纯rust实现 39 | 3. 增加发布包和docker里的script脚本 40 | 4. 支持图文混排消息 41 | 5. 重新整理输出架构,移除一些不必要的目标,如果同一Target有musl的目标就移除gnu的目标。 42 | 43 | v0.7.0 44 | ---------- 45 | 46 | 1. 增加对企业微信的新接口的支持(event和attachment) 47 | 2. 增加事件类型的回调入口 48 | 3. 对配置的 ```type``` 忽略大小写 49 | 4. 增加 ```ignore``` 类型事件,可以配置成直接忽略请求 50 | 5. 修复默认消息被匹配成空消息的问题 51 | 52 | v0.6.2 53 | ---------- 54 | 55 | 1. 增加空消息模板配置,用于处理空消息回调 56 | 2. 默认情况下空消息不返回错误 57 | 3. 修复第一次匹配未跳过隐藏命令的问题 58 | 4. 增加 ```order``` 选项,用于控制命令的匹配顺序,默认值为: 0 。越小匹配优先级越高 59 | 60 | v0.6.1 61 | ---------- 62 | 63 | 1. 更新依赖库 64 | 2. 修复有未知字段时解析提前中断的问题 65 | 3. 增加对消息的 **ChatType** 参数解析和 **WXWORK_ROBOT_CHAT_TYPE** 环境变量 66 | 67 | v0.6.0 68 | ---------- 69 | 70 | 1. 更新 [actix-web][1] 更新到新的大版本(^1.0.3) 71 | 2. 增加超时时间控制 72 | 3. 增加可自定义的消息Body大小配置 73 | 4. 增加可自定义连接数的配置 74 | 5. 移除对原来 [base64](https://crates.io/crates/base64) 模块的依赖(有BUG,这么高下载量的库实现都有BUG,rust生态真的不太行),改为自己实现的base64算法 75 | 6. 增加访问HTTPS的支持 76 | 77 | v0.5.1 78 | ---------- 79 | 80 | 1. 修订企业微信某些节点不支持"Expect": "100-continue"的问题 81 | 2. 调整 UserAgent 82 | 83 | v0.5.0 84 | ---------- 85 | 86 | 1. 增加一些通用的匹配选项 87 | 2. 默认改为大小写不敏感匹配 88 | 89 | [1]: https://actix.rs/ -------------------------------------------------------------------------------- /src/wxwork_robot/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod base64; 2 | pub mod command; 3 | pub mod command_runtime; 4 | pub mod error; 5 | pub mod message; 6 | pub mod project; 7 | 8 | use std::rc::Rc; 9 | use std::sync::{Arc, Mutex}; 10 | 11 | #[derive(Clone)] 12 | pub struct WxWorkProjectSet { 13 | pub projs: project::WxWorkProjectMap, 14 | pub cmds: Rc, 15 | pub events: Rc, 16 | } 17 | 18 | pub type WxWorkProjectSetShared = Arc>; 19 | 20 | lazy_static! { 21 | pub static ref GLOBAL_EMPTY_JSON_NULL: serde_json::Value = serde_json::Value::Null; 22 | } 23 | 24 | pub fn build_project_set(json: &serde_json::Value) -> Option { 25 | let kvs = if let Some(x) = json.as_object() { 26 | x 27 | } else { 28 | error!( 29 | "project set configure must be a json object, but real is {}", 30 | json 31 | ); 32 | return None; 33 | }; 34 | 35 | let projs_json_conf = if let Some(x) = kvs.get("projects") { 36 | x 37 | } else { 38 | error!("project set configure must has projects field {}", json); 39 | return None; 40 | }; 41 | 42 | let cmds_json_conf = if let Some(x) = kvs.get("cmds") { 43 | x 44 | } else { 45 | &GLOBAL_EMPTY_JSON_NULL 46 | }; 47 | 48 | let events_json_conf = if let Some(x) = kvs.get("events") { 49 | x 50 | } else { 51 | &GLOBAL_EMPTY_JSON_NULL 52 | }; 53 | 54 | let ret = WxWorkProjectSet { 55 | projs: project::WxWorkProject::parse(projs_json_conf), 56 | cmds: Rc::new(command::WxWorkCommand::parse(cmds_json_conf)), 57 | events: Rc::new(command::WxWorkCommand::parse(events_json_conf)), 58 | }; 59 | 60 | Some(ret) 61 | } 62 | 63 | pub fn build_project_set_shared(json: &serde_json::Value) -> Option { 64 | build_project_set(json).map(|x| Arc::new(Mutex::new(x))) 65 | } 66 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | authors = ["owent "] 3 | categories = [ 4 | "network-programming", 5 | "web-programming::http-server", 6 | "web-programming::http-client", 7 | "development-tools", 8 | ] 9 | description = "Robot service for WXWork" 10 | documentation = "https://github.com/owent/wxwork_robotd" 11 | edition = "2021" 12 | homepage = "https://crates.io/crates/wxwork_robotd" 13 | include = [ 14 | "src/**/*.rs", 15 | "Cargo.toml", 16 | "Cross.toml", 17 | "README.md", 18 | "tools/__init__.py", 19 | "tools/qyapi_wx_send_robot.py", 20 | ] 21 | keywords = ["wxwork", "wework", "robot", "weixin", "wechat"] 22 | license = "MIT OR Apache-2.0" 23 | name = "wxwork_robotd" 24 | readme = "README.md" 25 | repository = "https://github.com/owent/wxwork_robotd" 26 | version = "0.10.2" 27 | 28 | [[bin]] 29 | name = "wxwork_robotd" 30 | 31 | [dependencies] 32 | byteorder = "^1.5.0" 33 | bytes = "^1.8.0" 34 | chrono = ">=0.4.38" 35 | futures = "^0.3.31" 36 | handlebars = "^6.2.0" 37 | hex = ">=0.3.2" 38 | lazy_static = "^1.5.0" 39 | log = ">=0.4.22" 40 | mio-uds = "^0.6.7" 41 | ring = ">=0.16.0" 42 | rustls = ">=0.23.0" 43 | quick-xml = ">=0.37.0" 44 | regex = "^1.11.1" 45 | serde = { version = "1", features = ["derive"] } 46 | serde_json = "^1.0.133" 47 | aes = ">=0.8.4" 48 | cbc = ">=0.1.2" 49 | md-5 = ">=0.10.6" 50 | cipher = { version = ">=0.4.0", features = ["alloc"] } 51 | # https://github.com/RustCrypto 52 | actix-web = { version = "^4.9.0", features = ["rustls"] } 53 | actix-files = { version = ">=0.6.6" } 54 | 55 | [dependencies.awc] 56 | features = ["rustls"] 57 | version = "^3.5.1" 58 | 59 | [dependencies.clap] 60 | features = ["std", "suggestions", "color", "cargo", "help"] 61 | version = "^4.5.21" 62 | 63 | [dependencies.tokio] 64 | features = ["full"] 65 | version = "^1.41.0" 66 | 67 | [features] 68 | system-alloc = [] 69 | 70 | [profile] 71 | [profile.dev] 72 | codegen-units = 4 73 | debug = true 74 | debug-assertions = true 75 | incremental = false 76 | lto = false 77 | panic = "abort" 78 | rpath = false 79 | 80 | [profile.release] 81 | codegen-units = 1 82 | debug = false 83 | debug-assertions = false 84 | incremental = false 85 | lto = true 86 | opt-level = "z" 87 | panic = "abort" 88 | rpath = false 89 | -------------------------------------------------------------------------------- /.github/workflows/build-on-macos.yml: -------------------------------------------------------------------------------- 1 | name: "Build On macOS" 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: # job id, can be any string 7 | # Job name is Build And Publish 8 | name: Build 9 | # This job runs on Linux 10 | strategy: 11 | matrix: 12 | os: [macOS-latest] 13 | rust: [stable] 14 | target: [x86_64-apple-darwin] 15 | runs-on: ${{ matrix.os }} 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v3 19 | - name: Cache cargo modules 20 | uses: actions/cache@v3 21 | with: 22 | path: | 23 | ~/.cargo/registry 24 | ~/.cargo/git 25 | key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.lock') }} 26 | - name: Install toolchain 27 | uses: actions-rs/toolchain@v1 28 | with: 29 | toolchain: ${{ matrix.rust }} 30 | target: ${{ matrix.target }} 31 | default: true 32 | override: true 33 | components: "rustfmt, clippy, cargo, rust-docs" 34 | - name: Cargo Release build 35 | uses: actions-rs/cargo@v1 36 | if: ${{ matrix.target != 'x86_64-pc-windows-gnu' }} 37 | with: 38 | use-cross: false 39 | command: build 40 | args: --target ${{ matrix.target }} --release --jobs 2 --verbose 41 | - name: Run tests 42 | uses: actions-rs/cargo@v1 43 | with: 44 | command: test 45 | args: --release --verbose --target ${{ matrix.target }} --bin wxwork_robotd 46 | - name: Prepare package 47 | shell: bash 48 | if: ${{ contains(github.ref, 'refs/tags/') }} 49 | run: | 50 | if [[ -e "target/${{ matrix.target }}/release/etc" ]]; then 51 | rm -rf "target/${{ matrix.target }}/release/etc"; 52 | fi 53 | if [[ -e "target/${{ matrix.target }}/release/tools" ]]; then 54 | rm -rf "target/${{ matrix.target }}/release/tools"; 55 | fi 56 | mkdir -p "target/${{ matrix.target }}/release" 57 | cp -rf etc tools "target/${{ matrix.target }}/release"/ 58 | cd "target/${{ matrix.target }}/release/" 59 | mkdir -p bin; 60 | if [[ -e wxwork_robotd ]]; then 61 | cp -f wxwork_robotd bin/wxwork_robotd; 62 | else 63 | cp -f wxwork_robotd* bin/; 64 | fi 65 | tar -zcvf ${{ matrix.target }}.tar.gz etc bin tools; 66 | cd "$GITHUB_WORKSPACE" ; 67 | - uses: xresloader/upload-to-github-release@master 68 | if: ${{ contains(github.ref, 'refs/tags/') }} 69 | env: 70 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 71 | with: 72 | file: "target/${{ matrix.target }}/release/${{ matrix.target }}.tar.gz" 73 | tags: true 74 | draft: false 75 | prerelease: false 76 | overwrite: true 77 | -------------------------------------------------------------------------------- /.github/workflows/build-on-windows.yml: -------------------------------------------------------------------------------- 1 | name: "Build On Windows" 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: # job id, can be any string 7 | # Job name is Build And Publish 8 | name: Build 9 | # This job runs on Linux 10 | strategy: 11 | matrix: 12 | os: [windows-latest] 13 | rust: [stable] 14 | target: [x86_64-pc-windows-msvc] 15 | # x86_64-pc-windows-gnu, i686-pc-windows-gnu, 16 | runs-on: ${{ matrix.os }} 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v3 20 | - name: Cache cargo modules 21 | uses: actions/cache@v3 22 | with: 23 | path: | 24 | ~/.cargo/registry 25 | ~/.cargo/git 26 | key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.lock') }} 27 | - name: Install toolchain 28 | uses: actions-rs/toolchain@v1 29 | with: 30 | toolchain: ${{ matrix.rust }} 31 | target: ${{ matrix.target }} 32 | default: true 33 | override: true 34 | components: "rustfmt, clippy, cargo, rust-docs" 35 | - name: Cargo Release build 36 | uses: actions-rs/cargo@v1 37 | if: ${{ matrix.target != 'x86_64-pc-windows-gnu' }} 38 | with: 39 | use-cross: false 40 | command: build 41 | args: --target ${{ matrix.target }} --release --jobs 2 --verbose 42 | - name: Run tests 43 | uses: actions-rs/cargo@v1 44 | with: 45 | command: test 46 | args: --release --verbose --target ${{ matrix.target }} --bin wxwork_robotd 47 | - name: Prepare package 48 | shell: pwsh 49 | if: ${{ contains(github.ref, 'refs/tags/') }} 50 | run: | 51 | cd "$ENV:GITHUB_WORKSPACE/target/${{ matrix.target }}/release/" 52 | 53 | if ( Test-Path "tools" ) { Remove-Item -Recurse -Force "tools" } 54 | 55 | if ( Test-Path "etc" ) { Remove-Item -Recurse -Force "etc" } 56 | 57 | New-Item -Force -ItemType Directory "bin" 58 | 59 | Copy-Item -Force -Recurse "../../../tools" "./" 60 | 61 | Copy-Item -Force -Recurse "../../../etc" "./" 62 | 63 | Copy-Item -Force "wxwork_robotd.exe" "bin/" 64 | 65 | if ( Test-Path "${{ matrix.target }}.zip" ) { Remove-Item -Force "${{ matrix.target }}.zip" } 66 | 67 | Compress-Archive -DestinationPath "${{ matrix.target }}.zip" -Path etc,bin,tools 68 | 69 | cd "$ENV:GITHUB_WORKSPACE" 70 | - uses: xresloader/upload-to-github-release@master 71 | if: ${{ contains(github.ref, 'refs/tags/') }} 72 | env: 73 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 74 | with: 75 | file: "target/${{ matrix.target }}/release/${{ matrix.target }}.zip" 76 | tags: true 77 | draft: false 78 | prerelease: false 79 | overwrite: true 80 | -------------------------------------------------------------------------------- /etc/conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "listen": [":::12019", "0.0.0.0:12019"], 3 | "task_timeout": 4000, 4 | "workers": 8, 5 | "backlog": 256, 6 | "keep_alive": 5, 7 | "client_timeout": 5000, 8 | "client_shutdown": 5000, 9 | "max_connection_per_worker": 20480, 10 | "max_concurrent_rate_per_worker": 256, 11 | "payload_size_limit": 262144, 12 | "static_root": ".", 13 | "cmds": { 14 | "default": { 15 | "type": "echo", 16 | "echo": "我还不认识这个指令呐!({{WXWORK_ROBOT_CMD}})", 17 | "order": 999, 18 | "hidden": true 19 | }, 20 | "": { 21 | "type": "echo", 22 | "echo": "空消息,本群会话ID: {{WXWORK_ROBOT_CHAT_ID}}", 23 | "order": 999, 24 | "hidden": true 25 | }, 26 | "(help)|(帮助)|(指令列表)": { 27 | "type": "help", 28 | "description": "help|帮助|指令列表", 29 | "prefix": "### 可用指令列表\r\n", 30 | "order": 0 31 | }, 32 | "说\\s*(?P[^\\r\\n]+)": { 33 | "type": "echo", 34 | "echo": "{{WXWORK_ROBOT_CMD_MSG}}", 35 | "description": "说**消息内容**", 36 | "order": 2 37 | }, 38 | "执行命令\\s*(?P[^\\s]+)\\s*(?P[^\\s]*)": { 39 | "type": "spawn", 40 | "exec": "{{WXWORK_ROBOT_CMD_EXEC}}", 41 | "args": ["{{WXWORK_ROBOT_CMD_PARAM}}"], 42 | "cwd": "", 43 | "env": { 44 | "TEST_ENV": "all env key will be WXWORK_ROBOT_CMD_{NAME IN ENV} or WXWORK_ROBOT_PROJECT_{NAME}" 45 | }, 46 | "description": "执行命令**可执行文件路径** ***参数***", 47 | "output_type": "markdown", 48 | "order": 2 49 | } 50 | }, 51 | "events": { 52 | "add_to_chat": { 53 | "type": "echo", 54 | "echo": "Hi, 大家好" 55 | }, 56 | "enter_chat": { 57 | "type": "echo", 58 | "echo": "Hi, {{WXWORK_ROBOT_MSG_FROM_NAME}}" 59 | } 60 | }, 61 | "projects": [{ 62 | "name": "test_proj", 63 | "token": "hJqcu3uJ9Tn2gXPmxx2w9kkCkCE2EPYo", 64 | "encodingAESKey": "6qkdMrq68nTKduznJYO1A37W2oEgpkMUvkttRToqhUt", 65 | "env": { 66 | "testURL": "robots.txt" 67 | }, 68 | "cmds": { 69 | "http请求": { 70 | "type": "http", 71 | "method": "get", 72 | "url": "https://owent.net/{{WXWORK_ROBOT_PROJECT_TEST_URL}}", 73 | "post": "", 74 | "content_type": "", 75 | "headers": { 76 | "X-TEST": "value" 77 | }, 78 | "echo": "已发起HTTP请求,回包内容\r\n{{WXWORK_ROBOT_HTTP_RESPONSE}}", 79 | "case_insensitive": true, 80 | "multi_line": true, 81 | "unicode": true, 82 | "octal": true, 83 | "dot_matches_new_line": false, 84 | "order": 2 85 | }, 86 | "访问\\s*(?P[^\\r\\n]+)": { 87 | "type": "http", 88 | "url": "{{WXWORK_ROBOT_CMD_URL}}", 89 | "post": "", 90 | "echo": "HTTP请求: {{WXWORK_ROBOT_CMD_URL}}\r\n{{WXWORK_ROBOT_HTTP_RESPONSE}}", 91 | "description": "访问**URL地址**", 92 | "order": 2 93 | } 94 | }, 95 | "events": { 96 | "delete_from_chat": { 97 | "type": "echo", 98 | "echo": "再见" 99 | } 100 | } 101 | }] 102 | } 103 | -------------------------------------------------------------------------------- /etc/init.d/wxwork_robotd: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # wxwork_robotd 4 | # 5 | # chkconfig: 2345 80 20 6 | # description: Starts and stops a single wxwork_robotd instance on this system 7 | # 8 | 9 | ### BEGIN INIT INFO 10 | # Provides: wxwork_robotd 11 | # Required-Start: $network $named 12 | # Required-Stop: $network $named 13 | # Default-Start: 2 3 4 5 14 | # Default-Stop: 0 1 6 15 | # Short-Description: This service manages the wxwork_robotd daemon 16 | # Description: wxwork_robotd is a very scalable, schema-free and high-performance search solution supporting multi-tenancy and near realtime search. 17 | ### END INIT INFO 18 | 19 | # 20 | # init.d / servicectl compatibility (openSUSE) 21 | # 22 | if [ -f /etc/rc.status ]; then 23 | . /etc/rc.status 24 | rc_reset 25 | fi 26 | 27 | # 28 | # Source function library. 29 | # 30 | if [ -f /etc/rc.d/init.d/functions ]; then 31 | . /etc/rc.d/init.d/functions 32 | fi 33 | 34 | WXWORK_ROBOTD_HOME=/usr/local/wxwork_robotd 35 | WXWORK_ROBOTD_ETC=$WXWORK_ROBOTD_HOME/etc/conf.json 36 | PID_DIR=$WXWORK_ROBOTD_HOME/run 37 | LOG_DIR=$WXWORK_ROBOTD_HOME/log 38 | USER=wxwork_robot 39 | GROUP=wxwork_robot 40 | 41 | exec="$WXWORK_ROBOTD_HOME/bin/wxwork_robotd" 42 | prog="wxwork_robotd" 43 | pidfile="$PID_DIR/${prog}.pid" 44 | lockfile="$PID_DIR/${prog}.lock" 45 | 46 | if [ ! -x "$exec" ]; then 47 | echo "The wxwork_robotd startup script does not exists or it is not executable, tried: $exec" 48 | exit 1 49 | fi 50 | 51 | start() { 52 | # Ensure that the PID_DIR exists (it is cleaned at OS startup time) 53 | if [ -n "$PID_DIR" ] && [ ! -e "$PID_DIR" ]; then 54 | mkdir -p "$PID_DIR" && chown $USER:$GROUP "$PID_DIR" 55 | fi 56 | if [ -n "$LOG_DIR" ] && [ ! -e "$LOG_DIR" ]; then 57 | mkdir -p "$LOG_DIR" && chown $USER:$GROUP "$LOG_DIR" 58 | fi 59 | if [ -n "$pidfile" ] && [ ! -e "$pidfile" ]; then 60 | touch "$pidfile" && chown $USER:$GROUP "$pidfile" 61 | fi 62 | 63 | cd $WXWORK_ROBOTD_HOME 64 | echo -n $"Starting $prog: " 65 | # if not running, start it up here, usually something like "daemon $exec" 66 | daemon --user $USER --pidfile $pidfile $exec --pid-file "$pidfile" -c "$WXWORK_ROBOTD_ETC" --log "$LOG_DIR/wxwork_robotd.log" & 67 | retval=$? 68 | echo 69 | [ $retval -eq 0 ] && touch $lockfile && chown $USER:$GROUP $lockfile 70 | return $retval 71 | } 72 | 73 | stop() { 74 | echo -n $"Stopping $prog: " 75 | # stop it here, often "killproc $prog" 76 | killproc -p $pidfile -d 86400 $prog 77 | retval=$? 78 | echo 79 | [ $retval -eq 0 ] && rm -f $lockfile 80 | return $retval 81 | } 82 | 83 | restart() { 84 | stop 85 | start 86 | } 87 | 88 | rh_status() { 89 | # run checks to determine if the service is running or use generic status 90 | status -p $pidfile $prog 91 | } 92 | 93 | rh_status_q() { 94 | rh_status >/dev/null 2>&1 95 | } 96 | 97 | 98 | case "$1" in 99 | start) 100 | rh_status_q && exit 0 101 | $1 102 | ;; 103 | stop) 104 | rh_status_q || exit 0 105 | $1 106 | ;; 107 | restart) 108 | $1 109 | ;; 110 | reload) 111 | reload || exit 7 112 | $1 113 | ;; 114 | force-reload) 115 | force_reload 116 | ;; 117 | status) 118 | rh_status 119 | ;; 120 | condrestart|try-restart) 121 | rh_status_q || exit 0 122 | restart 123 | ;; 124 | *) 125 | echo $"Usage: $0 {start|stop|status|restart|condrestart|try-restart}" 126 | exit 2 127 | esac 128 | exit $? 129 | -------------------------------------------------------------------------------- /etc/systemv/wxwork_robotd: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | ### BEGIN INIT INFO 3 | # Provides: wxwork_robotd 4 | # Required-Start: $network $local_fs $remote_fs 5 | # Required-Stop: $remote_fs 6 | # Default-Start: 2 3 4 5 7 | # Default-Stop: 0 1 6 8 | # Short-Description: wxwork_robotd proxy services 9 | # Description: wxwork_robotd proxy services 10 | ### END INIT INFO 11 | 12 | # Acknowledgements: owent 13 | 14 | WXWORK_ROBOTD_HOME=/usr/local/wxwork_robotd 15 | WXWORK_ROBOTD_ETC=$WXWORK_ROBOTD_HOME/etc/conf.json 16 | PID_DIR=$WXWORK_ROBOTD_HOME/run 17 | LOG_DIR=$WXWORK_ROBOTD_HOME/log 18 | USER=wxwork_robot 19 | GROUP=wxwork_robot 20 | 21 | DESC=wxwork_robotd 22 | NAME=wxwork_robotd 23 | DAEMON="$WXWORK_ROBOTD_HOME/bin/wxwork_robotd" 24 | PIDFILE="$PID_DIR/${prog}.pid" 25 | SCRIPTNAME=/etc/init.d/$NAME 26 | 27 | DAEMON_OPTS="--pid-file $pidfile -c $WXWORK_ROBOTD_ETC --log $LOG_DIR/wxwork_robotd.log" 28 | 29 | # Exit if the package is not installed 30 | [ -x $DAEMON ] || exit 0 31 | 32 | # Read configuration variable file if it is present 33 | [ -r /etc/default/$NAME ] && . /etc/default/$NAME 34 | 35 | # Load the VERBOSE setting and other rcS variables 36 | . /lib/init/vars.sh 37 | 38 | # Define LSB log_* functions. 39 | # Depend on lsb-base (>= 3.0-6) to ensure that this file is present. 40 | . /lib/lsb/init-functions 41 | 42 | # 43 | # Function that starts the daemon/service 44 | # 45 | do_start() 46 | { 47 | mkdir -p $LOG_DIR; 48 | mkdir -p $PID_DIR; 49 | # Return 50 | # 0 if daemon has been started 51 | # 1 if daemon was already running 52 | # 2 if daemon could not be started 53 | # 3 if configuration file not ready for daemon 54 | start-stop-daemon --start --quiet --pidfile $PIDFILE --exec $DAEMON --background -m -- $DAEMON_OPTS \ 55 | || return 2 56 | # Add code here, if necessary, that waits for the process to be ready 57 | # to handle requests from services started subsequently which depend 58 | # on this one. As a last resort, sleep for some time. 59 | } 60 | 61 | # 62 | # Function that stops the daemon/service 63 | # 64 | do_stop() 65 | { 66 | # Return 67 | # 0 if daemon has been stopped 68 | # 1 if daemon was already stopped 69 | # 2 if daemon could not be stopped 70 | # other if a failure occurred 71 | start-stop-daemon --stop --quiet --retry=TERM/30/KILL/5 --pidfile $PIDFILE 72 | RETVAL="$?" 73 | [ "$RETVAL" = 2 ] && return 2 74 | # Wait for children to finish too if this is a daemon that forks 75 | # and if the daemon is only ever run from this initscript. 76 | # If the above conditions are not satisfied then add some other code 77 | # that waits for the process to drop all resources that could be 78 | # needed by services started subsequently. A last resort is to 79 | # sleep for some time. 80 | start-stop-daemon --stop --quiet --oknodo --retry=0/30/KILL/5 --exec $DAEMON 81 | [ "$?" = 2 ] && return 2 82 | # Many daemons don't delete their pidfiles when they exit. 83 | rm -f $PIDFILE 84 | return "$RETVAL" 85 | } 86 | 87 | # 88 | # Function that sends a SIGHUP to the daemon/service 89 | # 90 | do_reload() { 91 | # 92 | # If the daemon can reload its configuration without 93 | # restarting (for example, when it is sent a SIGHUP), 94 | # then implement that here. 95 | # 96 | start-stop-daemon --stop --signal 1 --quiet --pidfile $PIDFILE 97 | return 0 98 | } 99 | 100 | case "$1" in 101 | start) 102 | [ "$VERBOSE" != no ] && log_daemon_msg "Starting $DESC " "$NAME" 103 | do_start 104 | case "$?" in 105 | 0|1) [ "$VERBOSE" != no ] && log_end_msg 0 ;; 106 | 2) [ "$VERBOSE" != no ] && log_end_msg 1 ;; 107 | esac 108 | ;; 109 | stop) 110 | [ "$VERBOSE" != no ] && log_daemon_msg "Stopping $DESC" "$NAME" 111 | do_stop 112 | case "$?" in 113 | 0|1) [ "$VERBOSE" != no ] && log_end_msg 0 ;; 114 | 2) [ "$VERBOSE" != no ] && log_end_msg 1 ;; 115 | esac 116 | ;; 117 | status) 118 | status_of_proc "$DAEMON" "$NAME" && exit 0 || exit $? 119 | ;; 120 | reload|force-reload) 121 | # 122 | # If do_reload() is not implemented then leave this commented out 123 | # and leave 'force-reload' as an alias for 'restart'. 124 | # 125 | log_daemon_msg "Reloading $DESC" "$NAME" 126 | do_reload 127 | log_end_msg $? 128 | ;; 129 | restart|force-reload) 130 | # 131 | # If the "reload" option is implemented then remove the 132 | # 'force-reload' alias 133 | # 134 | log_daemon_msg "Restarting $DESC" "$NAME" 135 | do_stop 136 | case "$?" in 137 | 0|1) 138 | do_start 139 | case "$?" in 140 | 0) log_end_msg 0 ;; 141 | 1) log_end_msg 1 ;; # Old process is still running 142 | *) log_end_msg 1 ;; # Failed to start 143 | esac 144 | ;; 145 | *) 146 | # Failed to stop 147 | log_end_msg 1 148 | ;; 149 | esac 150 | ;; 151 | *) 152 | #echo "Usage: $SCRIPTNAME {start|stop|restart}" >&2 153 | echo "Usage: $SCRIPTNAME {start|stop|status|restart}" >&2 154 | exit 3 155 | ;; 156 | esac 157 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | // When the `system-alloc` feature is used, use the System Allocator 2 | #[cfg(feature = "system-alloc")] 3 | use std::alloc::System; 4 | #[cfg(feature = "system-alloc")] 5 | #[global_allocator] 6 | static GLOBAL: System = System; 7 | 8 | // crates 9 | #[macro_use] 10 | extern crate clap; 11 | extern crate actix_files; 12 | extern crate actix_web; 13 | extern crate awc; 14 | extern crate futures; 15 | #[macro_use] 16 | extern crate log; 17 | extern crate bytes; 18 | extern crate chrono; 19 | extern crate handlebars; 20 | extern crate hex; 21 | extern crate quick_xml; 22 | extern crate serde; 23 | #[macro_use] 24 | extern crate serde_json; 25 | extern crate aes; 26 | extern crate cbc; 27 | extern crate cipher; 28 | extern crate md5; 29 | extern crate regex; 30 | extern crate ring; 31 | #[macro_use] 32 | extern crate lazy_static; 33 | extern crate byteorder; 34 | extern crate tokio; 35 | 36 | // #[macro_use] 37 | // extern crate json; 38 | 39 | // import packages 40 | // use std::sync::Arc; 41 | use crate::actix_files::Files; 42 | use actix_web::{middleware::Logger, web, App, HttpServer}; 43 | 44 | use std::io; 45 | use std::net::TcpListener; 46 | use std::time::Duration; 47 | 48 | pub mod app; 49 | pub mod handles; 50 | pub mod logger; 51 | pub mod wxwork_robot; 52 | 53 | #[actix_web::main] 54 | async fn main() -> io::Result<()> { 55 | let mut app_env = app::app(); 56 | if app_env.debug { 57 | if let Err(e) = logger::init_with_level( 58 | log::Level::Debug, 59 | app_env.log, 60 | app_env.log_rotate, 61 | app_env.log_rotate_size, 62 | ) { 63 | eprintln!("Setup debug log failed: {:?}.", e); 64 | return Err(std::io::Error::from(std::io::ErrorKind::InvalidData)); 65 | } 66 | } else if let Err(e) = logger::init(app_env.log, app_env.log_rotate, app_env.log_rotate_size) { 67 | eprintln!("Setup log failed: {:?}.", e); 68 | return Err(std::io::Error::from(std::io::ErrorKind::InvalidData)); 69 | } 70 | 71 | if !app_env.reload() { 72 | eprintln!("Load configure {} failed.", app_env.configure); 73 | return Err(std::io::Error::from(std::io::ErrorKind::InvalidInput)); 74 | } 75 | 76 | let run_info = app_env.text_info(); 77 | let mut server = HttpServer::new(move || { 78 | let app = App::new().wrap(Logger::new( 79 | "[ACCESS] %a \"%r\" %s %b \"%{Referer}i\" \"%{User-Agent}i %{Content-Type}i\" %T", 80 | )); 81 | let reg_move_default = app_env; 82 | let reg_move_robot = app_env; 83 | 84 | let app = app 85 | // ====== register for index ====== 86 | .service( 87 | web::resource(app_env.prefix.to_string()) 88 | .app_data(web::PayloadConfig::default().limit(app_env.conf.payload_size_limit)) 89 | .to(move |req| handles::default::dispatch_default_index(reg_move_default, req)), 90 | ); 91 | 92 | // ====== register for static files ====== 93 | let app = if let Some(static_root) = app_env.conf.static_root.as_ref() { 94 | app.service(Files::new("/", static_root).show_files_listing()) 95 | } else { 96 | app 97 | }; 98 | 99 | // ====== register for project ====== 100 | app.service( 101 | web::resource(format!("{}{{project}}/", app_env.prefix).as_str()) 102 | .app_data(web::PayloadConfig::default().limit(app_env.conf.payload_size_limit)) 103 | .to(move |req, body| { 104 | handles::robot::dispatch_robot_request(reg_move_robot, req, body) 105 | }), 106 | ) 107 | // app_env.setup(app) 108 | }); 109 | 110 | if app_env.debug { 111 | server = server.workers(1); 112 | } else { 113 | server = server.workers(app_env.conf.workers); 114 | } 115 | server = server 116 | .backlog(app_env.conf.backlog) 117 | .max_connections(app_env.conf.max_connection_per_worker) 118 | .max_connection_rate(app_env.conf.max_concurrent_rate_per_worker) 119 | .keep_alive(Duration::from_secs(app_env.conf.keep_alive)) 120 | .client_request_timeout(Duration::from_millis(app_env.conf.client_timeout)) 121 | .client_disconnect_timeout(Duration::from_millis(app_env.conf.client_shutdown)); 122 | 123 | // server = server.client_timeout(app_env.conf.task_timeout); 124 | let mut listened_count = 0; 125 | for ref host in app_env.get_hosts() { 126 | let listener = match TcpListener::bind(host.as_str()) { 127 | Ok(x) => x, 128 | Err(e) => { 129 | eprintln!( 130 | "Listen address {} failed and ignore this address: {}", 131 | host, e 132 | ); 133 | error!( 134 | "Listen address {} failed and ignore this address: {}", 135 | host, e 136 | ); 137 | continue; 138 | } 139 | }; 140 | 141 | server = match server.listen(listener) { 142 | Ok(x) => x, 143 | Err(e) => { 144 | eprintln!( 145 | "Bind address {} success but listen failed and ignore this address: {}", 146 | host, e 147 | ); 148 | error!( 149 | "Bind address {} success but listen failed and ignore this address: {}", 150 | host, e 151 | ); 152 | return Err(e); 153 | } 154 | }; 155 | 156 | println!("listen on {} success", host); 157 | listened_count += 1; 158 | } 159 | 160 | if listened_count == 0 { 161 | return Ok(()); 162 | } 163 | 164 | info!("{}", run_info); 165 | let ret = server.run().await; 166 | if let Err(ref e) = ret { 167 | eprintln!("Start robotd service failed: {}", e); 168 | error!("Start robotd service failed: {}", e); 169 | } 170 | 171 | ret 172 | } 173 | -------------------------------------------------------------------------------- /.github/workflows/build-on-linux.yml: -------------------------------------------------------------------------------- 1 | name: "Build On Linux" 2 | 3 | on: # @see https://help.github.com/en/articles/events-that-trigger-workflows#webhook-events 4 | push: 5 | branches: # Array of patterns that match refs/heads 6 | - main # Push events on master branch 7 | tags: 8 | - "*" 9 | pull_request: 10 | branches: 11 | - main 12 | 13 | jobs: 14 | format_and_lint: 15 | name: "Format and lint" 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v3 20 | - name: Cache cargo modules 21 | uses: actions/cache@v3 22 | with: 23 | path: | 24 | ~/.cargo/registry 25 | ~/.cargo/git 26 | key: ${{ runner.os }}-format_and_lint-cargo-${{ hashFiles('**/Cargo.lock') }} 27 | - name: Install rust toolchain for host 28 | uses: actions-rs/toolchain@v1 29 | with: 30 | toolchain: stable 31 | target: x86_64-unknown-linux-gnu 32 | override: true 33 | default: true 34 | components: "rustfmt, clippy, cargo, rust-docs" 35 | - name: Format and lint 36 | shell: bash 37 | run: | 38 | cargo fmt --all -- --check 39 | cargo clippy 40 | build: # job id, can be any string 41 | # Job name is Build And Publish 42 | name: Build 43 | # This job runs on Linux 44 | strategy: 45 | matrix: 46 | os: [ubuntu-latest] 47 | rust: [stable] 48 | target: [ 49 | x86_64-unknown-linux-gnu, 50 | x86_64-unknown-linux-musl, 51 | aarch64-unknown-linux-gnu, 52 | aarch64-unknown-linux-musl, 53 | arm-unknown-linux-gnueabi, 54 | armv7-unknown-linux-gnueabihf, 55 | armv7-unknown-linux-musleabihf, 56 | arm-unknown-linux-musleabihf, 57 | arm-unknown-linux-musleabi, 58 | #mips-unknown-linux-gnu, 59 | #mipsel-unknown-linux-gnu, 60 | #mips64-unknown-linux-gnuabi64, 61 | #mips64el-unknown-linux-gnuabi64, 62 | #mips-unknown-linux-musl, 63 | #mipsel-unknown-linux-musl, 64 | #powerpc-unknown-linux-gnu, 65 | #powerpc64-unknown-linux-gnu, 66 | #powerpc64le-unknown-linux-gnu, 67 | i686-unknown-linux-gnu, 68 | i686-unknown-linux-musl, 69 | aarch64-linux-android, 70 | armv7-linux-androideabi, 71 | x86_64-linux-android, 72 | #x86_64-unknown-netbsd, 73 | #x86_64-unknown-freebsd, 74 | #x86_64-sun-solaris, 75 | ] 76 | runs-on: ${{ matrix.os }} 77 | steps: 78 | - name: Checkout 79 | uses: actions/checkout@v3 80 | - name: Cache cargo modules 81 | uses: actions/cache@v3 82 | with: 83 | path: | 84 | ~/.cargo/registry 85 | ~/.cargo/git 86 | key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.lock') }} 87 | - name: Install rust toolchain for host 88 | uses: actions-rs/toolchain@v1 89 | if: ${{ matrix.target == 'x86_64-unknown-linux-gnu' }} 90 | with: 91 | toolchain: ${{ matrix.rust }} 92 | target: ${{ matrix.target }} 93 | override: true 94 | default: true 95 | components: "rustfmt, clippy, cargo, rust-docs" 96 | - name: Install rust toolchain for cross 97 | uses: actions-rs/toolchain@v1 98 | if: ${{ matrix.target != 'x86_64-unknown-linux-gnu' }} 99 | with: 100 | toolchain: ${{ matrix.rust }} 101 | target: x86_64-unknown-linux-gnu 102 | override: true 103 | default: true 104 | components: "rustfmt, clippy, cargo, rust-docs" 105 | - name: Install Cross 106 | shell: bash 107 | if: ${{ matrix.target != 'x86_64-unknown-linux-gnu' }} 108 | run: | 109 | cargo install cross --git https://github.com/cross-rs/cross 110 | cargo install xargo 111 | - name: Cargo Release build 112 | uses: actions-rs/cargo@v1 113 | if: ${{ matrix.target == 'x86_64-unknown-linux-gnu' }} 114 | with: 115 | use-cross: false 116 | command: build 117 | args: --target ${{ matrix.target }} --release --jobs 2 --verbose 118 | - name: Run tests 119 | uses: actions-rs/cargo@v1 120 | if: ${{ matrix.target == 'x86_64-unknown-linux-gnu' }} 121 | with: 122 | command: test 123 | args: --release --verbose --target ${{ matrix.target }} --bin wxwork_robotd 124 | - name: Cross Release build 125 | uses: actions-rs/cargo@v1 126 | # shell: bash 127 | if: ${{ matrix.target != 'x86_64-unknown-linux-gnu' }} 128 | # run: cross --target ${{ matrix.target }} --release --jobs 2 --verbose 129 | with: 130 | use-cross: true 131 | command: build 132 | args: --target ${{ matrix.target }} --release --jobs 2 --verbose 133 | - name: Prepare package 134 | shell: bash 135 | if: ${{ github.ref_type == 'tag' }} 136 | run: | 137 | if [[ -e "target/${{ matrix.target }}/release/etc" ]]; then 138 | rm -rf "target/${{ matrix.target }}/release/etc"; 139 | fi 140 | if [[ -e "target/${{ matrix.target }}/release/tools" ]]; then 141 | rm -rf "target/${{ matrix.target }}/release/tools"; 142 | fi 143 | mkdir -p "target/${{ matrix.target }}/release" 144 | cp -rf etc tools "target/${{ matrix.target }}/release"/ 145 | cd "target/${{ matrix.target }}/release/" 146 | mkdir -p bin; 147 | if [[ -e wxwork_robotd ]]; then 148 | cp -f wxwork_robotd bin/wxwork_robotd; 149 | else 150 | cp -f wxwork_robotd* bin/; 151 | fi 152 | tar -zcvf ${{ matrix.target }}.tar.gz etc bin tools; 153 | cd "$GITHUB_WORKSPACE" ; 154 | - uses: xresloader/upload-to-github-release@main 155 | if: ${{ github.ref_type == 'tag' }} 156 | env: 157 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 158 | with: 159 | file: "target/${{ matrix.target }}/release/${{ matrix.target }}.tar.gz" 160 | tags: true 161 | draft: false 162 | prerelease: false 163 | overwrite: true 164 | - name: Update docker 165 | shell: bash 166 | if: ${{ github.ref_type == 'tag' && matrix.target == 'x86_64-unknown-linux-musl' }} 167 | run: | 168 | cd "target/${{ matrix.target }}/release/" 169 | which docker || true; 170 | cp -f ../../../Dockerfile ./ ; 171 | TAG_NAME="$(echo "${{ github.ref }}" | awk 'match($0, /refs\/tags\/(.+)/, tag_name) {print tag_name[1]}')"; 172 | echo "${{ secrets.DOCKER_TOKEN }}" | docker login -u "owt5008137" --password-stdin docker.io 173 | docker build --force-rm --tag docker.io/owt5008137/wxwork_robotd:latest -f Dockerfile . ; 174 | docker tag docker.io/owt5008137/wxwork_robotd:latest docker.io/owt5008137/wxwork_robotd:$TAG_NAME ; 175 | docker push docker.io/owt5008137/wxwork_robotd:latest ; 176 | docker push docker.io/owt5008137/wxwork_robotd:$TAG_NAME ; 177 | cd "$GITHUB_WORKSPACE" ; 178 | -------------------------------------------------------------------------------- /src/logger/mod.rs: -------------------------------------------------------------------------------- 1 | use std::cell::{RefCell, RefMut}; 2 | use std::fs::{create_dir_all, File, OpenOptions}; 3 | use std::io::Write; 4 | use std::path::Path; 5 | use std::sync::{Arc, Mutex}; 6 | use std::time::SystemTime; 7 | 8 | use chrono::Local; 9 | use log::{Level, Log, Metadata, Record, SetLoggerError}; 10 | 11 | struct FileRotateLoggerRuntime { 12 | pub current_rotate: i32, 13 | pub current_size: usize, 14 | pub current_file: Option, 15 | } 16 | 17 | struct FileRotateLogger { 18 | pub level: Level, 19 | pub file_path: String, 20 | pub rotate_num: i32, 21 | pub rotate_size: usize, 22 | pub runtime: Arc>>, 23 | } 24 | 25 | enum FileRotateLoggerWrapper { 26 | Logger(FileRotateLogger), 27 | Nil, 28 | } 29 | 30 | impl FileRotateLogger { 31 | fn next_file(&self, runtime: &mut RefMut) -> bool { 32 | if let Some(ref file) = runtime.current_file { 33 | if let Err(e) = file.sync_all() { 34 | eprintln!("Try to sync log file failed: {:?}", e); 35 | } 36 | } 37 | 38 | runtime.current_rotate = (runtime.current_rotate + 1) % self.rotate_num; 39 | let file_path = get_log_path(self.file_path.as_str(), runtime.current_rotate); 40 | 41 | let mut options = OpenOptions::new(); 42 | options.write(true).create(true).truncate(true); 43 | match options.open(file_path.as_str()) { 44 | Ok(file) => { 45 | runtime.current_file = Some(file); 46 | runtime.current_size = 0; 47 | 48 | if self.level >= Level::Debug { 49 | println!("Open new log file {} success", file_path) 50 | } 51 | 52 | true 53 | } 54 | Err(e) => { 55 | eprintln!("Try to open log file {} failed: {:?}", file_path, e); 56 | false 57 | } 58 | } 59 | } 60 | } 61 | 62 | impl Log for FileRotateLogger { 63 | fn enabled(&self, metadata: &Metadata) -> bool { 64 | metadata.level() <= self.level 65 | } 66 | 67 | fn log(&self, record: &Record) { 68 | if self.enabled(record.metadata()) { 69 | let guard = match self.runtime.lock() { 70 | Ok(guard) => guard, 71 | Err(_) => { 72 | eprintln!("Try to lock logger failed"); 73 | return; 74 | } 75 | }; 76 | 77 | let runtime_rc = &*guard; 78 | let mut runtime = runtime_rc.borrow_mut(); 79 | 80 | // open log file for the first time 81 | if runtime.current_file.is_none() { 82 | let full_file_path = get_log_path(self.file_path.as_str(), runtime.current_rotate); 83 | let file_path = Path::new(full_file_path.as_str()); 84 | if let Some(dir_path) = file_path.parent() { 85 | if !dir_path.as_os_str().is_empty() 86 | && (!dir_path.exists() || !dir_path.is_dir()) 87 | && create_dir_all(dir_path).is_err() 88 | { 89 | eprintln!("Try to create log directory {:?} failed", dir_path); 90 | } 91 | } 92 | 93 | let mut options = OpenOptions::new(); 94 | options.create(true).append(true).truncate(false); 95 | if let Ok(file) = options.open(file_path) { 96 | if let Ok(meta) = file.metadata() { 97 | runtime.current_size = meta.len() as usize; 98 | } else { 99 | eprintln!("Try to read meta of log file {} failed", full_file_path); 100 | } 101 | runtime.current_file = Some(file); 102 | } else { 103 | eprintln!("Try to open log file {} failed", full_file_path); 104 | } 105 | 106 | if runtime.current_size >= self.rotate_size { 107 | if self.level >= Level::Debug { 108 | println!( 109 | "Old log file {} success with size {} is greater than the rotate size {}, try to use new log file", 110 | full_file_path, runtime.current_size, self.rotate_size 111 | ) 112 | } 113 | 114 | self.next_file(&mut runtime); 115 | } else if self.level >= Level::Debug { 116 | println!( 117 | "Open old log file {} success with size {}(less than the rotate size {})", 118 | full_file_path, runtime.current_size, self.rotate_size 119 | ) 120 | } 121 | } 122 | 123 | let mut written_len = 0; 124 | if let Some(ref mut file) = runtime.current_file { 125 | let now = Local::now(); 126 | let content = if record.file().is_some() && record.line().is_some() { 127 | format!( 128 | "{} {:<5} [{}:{}@{}] {}\n", 129 | now.format("%Y-%m-%d %H:%M:%S"), 130 | record.level().to_string(), 131 | record.file().unwrap(), 132 | record.line().unwrap(), 133 | record.module_path().unwrap_or_default(), 134 | record.args(), 135 | ) 136 | } else { 137 | format!( 138 | "{} {:<5} [{}] {}\n", 139 | now.format("%Y-%m-%d %H:%M:%S"), 140 | record.level().to_string(), 141 | record.module_path().unwrap_or_default(), 142 | record.args(), 143 | ) 144 | }; 145 | 146 | if let Ok(len) = file.write(content.as_bytes()) { 147 | written_len = len; 148 | } 149 | } 150 | runtime.current_size += written_len; 151 | 152 | if runtime.current_size >= self.rotate_size { 153 | self.next_file(&mut runtime); 154 | } 155 | } 156 | } 157 | 158 | fn flush(&self) { 159 | let guard = match self.runtime.lock() { 160 | Ok(guard) => guard, 161 | Err(_) => { 162 | eprintln!("Try to lock logger failed"); 163 | return; 164 | } 165 | }; 166 | 167 | let runtime_rc = &*guard; 168 | let runtime = runtime_rc.borrow_mut(); 169 | 170 | // flush into device 171 | if let Some(ref file) = runtime.current_file { 172 | if let Err(e) = file.sync_all() { 173 | eprintln!("Try to sync log file failed: {:?}", e); 174 | } 175 | } 176 | } 177 | } 178 | 179 | fn get_log_path(file_path: &str, rotate_num: i32) -> String { 180 | format!("{}.{}", file_path, rotate_num) 181 | } 182 | 183 | static mut SHARED_FILE_ROTATE_LOG: FileRotateLoggerWrapper = FileRotateLoggerWrapper::Nil; 184 | 185 | /// Initializes the global logger with a FileRotateLogger instance with 186 | /// `max_log_level` set to a specific log level. 187 | /// 188 | /// ``` 189 | /// # #[macro_use] extern crate log; 190 | /// # extern crate logger; 191 | /// # 192 | /// # fn main() { 193 | /// logger::init_with_level(log::Level::Warn).unwrap(); 194 | /// 195 | /// warn!("This is an example message."); 196 | /// info!("This message will not be logged."); 197 | /// # } 198 | /// ``` 199 | pub fn init_with_level( 200 | level: Level, 201 | file_path: &str, 202 | rotate_num: i32, 203 | rotate_size: usize, 204 | ) -> Result<(), SetLoggerError> { 205 | let mut init_rotate = 0; 206 | let mut last_modify_time = SystemTime::UNIX_EPOCH; 207 | for idx in 0..rotate_num { 208 | let test_file_path = get_log_path(file_path, idx); 209 | let test_file = File::open(test_file_path); 210 | if let Ok(file) = test_file { 211 | if let Ok(meta) = file.metadata() { 212 | if let Ok(time) = meta.modified() { 213 | if time > last_modify_time { 214 | last_modify_time = time; 215 | init_rotate = idx; 216 | } 217 | } 218 | } 219 | } else { 220 | init_rotate = if idx > 0 { idx - 1 } else { idx }; 221 | break; 222 | } 223 | } 224 | 225 | unsafe { 226 | SHARED_FILE_ROTATE_LOG = FileRotateLoggerWrapper::Logger(FileRotateLogger { 227 | level, 228 | file_path: String::from(file_path), 229 | rotate_num: if rotate_num > 0 { rotate_num } else { 1 }, 230 | rotate_size, 231 | runtime: Arc::new(Mutex::new(RefCell::new(FileRotateLoggerRuntime { 232 | current_rotate: init_rotate, 233 | current_size: 0, 234 | current_file: None, 235 | }))), 236 | }); 237 | 238 | if let FileRotateLoggerWrapper::Logger(ref l) = SHARED_FILE_ROTATE_LOG { 239 | log::set_logger(l)?; 240 | } 241 | } 242 | // log::set_boxed_logger(logger_inst)?; 243 | log::set_max_level(level.to_level_filter()); 244 | Ok(()) 245 | } 246 | 247 | /// Initializes the global logger with a FileRotateLogger instance with 248 | /// `max_log_level` set to `LogLevel::Trace`. 249 | /// 250 | /// ``` 251 | /// # #[macro_use] extern crate log; 252 | /// # extern crate logger; 253 | /// # 254 | /// # fn main() { 255 | /// logger::init().unwrap(); 256 | /// warn!("This is an example message."); 257 | /// # } 258 | /// ``` 259 | pub fn init(file_path: &str, rotate_num: i32, rotate_size: usize) -> Result<(), SetLoggerError> { 260 | init_with_level(Level::Info, file_path, rotate_num, rotate_size) 261 | } 262 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright 2024 owent 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 企业微信机器人接入 2 | 3 | | | [Linux+OSX][linux-link] | [Windows MSVC+GNU][windows-link] | 4 | |:-------------------------:|:-----------------------:|:--------------------------------:| 5 | | Build & Publish | ![linux-badge] | ![windows-badge] | 6 | 7 | [linux-badge]: https://travis-ci.org/owt5008137/wxwork_robotd.svg?branch=master "Travis build status" 8 | [linux-link]: https://travis-ci.org/owt5008137/wxwork_robotd "Travis build status" 9 | [windows-badge]: https://ci.appveyor.com/api/projects/status/ht5pks682ehe2vkt?svg=true "AppVeyor build status" 10 | [windows-link]: https://ci.appveyor.com/project/owt5008137/wxwork-robotd "AppVeyor build status" 11 | 12 | 来自企业微信机器人的请求会先按填入的URL的最后一节匹配到配置中 ```projects``` 里 ```name``` 对应的项目中,然后优先从 ```projects``` 内尝试匹配命令,再从全局域匹配命令。 命令的类型( ```type``` )支持 **echo (输出消息)** 、 **http (HTTP请求)** 、 **help (帮助信息)** 和 **spawn (子进程执行命令)** 。 配置项中的 *echo* 、 *exec* 、 *args* 、 *url* 、 *post* 、 *prefix* 、 *suffix* 会使用模板引擎尝试对内容进行替换(传参),传入的参数可以是匹配命令里的匹配结果,也可以是预先配置好的环境变量。 13 | 14 | 如果是 **spawn (子进程执行命令)** 类型的请求,配置里的环境变量在子进程中也可用。 15 | 16 | 可以通过 ```./wxwork_robotd -h``` 查看可用选项。[etc](etc) 目录里有各种系统的启动脚本模板。 17 | 18 | 19 | ## 下载和安装 20 | 21 | 您可以在 https://github.com/owt5008137/wxwork_robotd/releases 下载预发布包,解压即可。 22 | 23 | > 或者使用 rust 的cargo 命令```cargo install wxwork_robotd``` 来直接安装到 ```$HOME/.cargo/bin``` 。 24 | > 这种方式只会安装可执行程序,其他的示例文件和工具脚本可以在 ```$HOME/.cargo/registry/src/github.com-*/wxwork_robotd-*``` 里找到。 25 | 26 | 发布包文件说明: 27 | 28 | 1. [etc/conf.json](etc/conf.json) :示例的配置文件。 29 | 2. [etc/firewalld/wxwork_robotd.xml](etc/firewalld/wxwork_robotd.xml) : 示例的firewalld配置文件。 30 | > 请先修改端口号为和配置文件保持一致。 31 | > 然后复制到 ```/etc/firewalld/services/wxwork_robotd.xml``` 后执行 ```sudo firewall-cmd --permanent --add-service=wxwork_robotd``` 即可。 32 | 33 | 3. [etc/systemd/wxwork_robotd.service](etc/systemd/wxwork_robotd.service) : 示例的systemd服务配置文件 34 | > 请先修改部署目录和实际使用的路径保持一致 35 | > 然后复制到 ```/usr/lib/systemd/system/wxwork_robotd.service``` 后执行 ```sudo systemctl enable wxwork_robotd && sudo systemctl start wxwork_robotd``` 即可。 36 | 37 | 4. [etc/systemv/wxwork_robotd](etc/systemv/wxwork_robotd) : 示例的用于 systemv 的服务配置文件 38 | 5. [etc/init.d/wxwork_robotd](etc/init.d/wxwork_robotd) : 示例的用于 init.d 的服务配置文件 39 | 6. [tools](tools) : 用于主动发机器人消息的工具脚本 40 | 41 | 42 | ## 主动发消息接口 43 | 44 | [tools](tools) 目录包含用于主动发消息的**企业微信机器人脚本**,兼容 python 2.7-3.X 。 45 | 46 | > python 2.7 依赖 [requests](https://pypi.org/project/requests/)库。 可通过 ```pip install requests``` 来安装。 47 | 48 | ## 环境变量命名 49 | 50 | + CMD和匹配的内容可以通过 ```{{WXWORK_ROBOT_CMD}}``` 和 ```{{WXWORK_ROBOT_CMD_<变量名或匹配名>}}``` 来获取。 51 | + ```projects``` 中的内容可以通过 ```{{WXWORK_ROBOT_PROJECT_<变量名或匹配名>}}``` 来获取。 52 | + 环境变量只会导出类型为字符串、数字或者布尔值的内容,不支持嵌套内容 53 | + 可用的环境变量 54 | * WXWORK_ROBOT_VERSION : 当前机器人版本号(版本>=0.10.2) 55 | * WXWORK_ROBOT_WEBHOOK_KEY : 当前消息对应机器人的WebhookURL里的key字段(可用来回发消息,版本>=0.3.3) 56 | * WXWORK_ROBOT_WEBHOOK_URL : 当前消息对应机器人的WebhookURL(可用来回发消息,版本>=0.3.3) 57 | * WXWORK_ROBOT_CMD : 当前执行命令的完整匹配消息 58 | * WXWORK_ROBOT_CMD_{VARNAME} : 当前执行命令的匹配参数(必须是命名匹配式)或配置的环境变量 59 | * WXWORK_ROBOT_PROJECT_NAME : 配置的项目名 60 | * WXWORK_ROBOT_PROJECT_TOKEN : 配置的项目验证token 61 | * WXWORK_ROBOT_PROJECT_ENCODING_AES_KEY : 配置的项目base64的aes key 62 | * WXWORK_ROBOT_PROJECT_{VARNAME} : 配置的项目中的环境变量 63 | * WXWORK_ROBOT_MSG_FROM_USER_ID : 发消息者的用户id(版本>=0.3.6) 64 | * WXWORK_ROBOT_MSG_FROM_NAME : 发消息者的用户名称(版本>=0.3.6) 65 | * WXWORK_ROBOT_MSG_FROM_ALIAS : 发消息者的用户别名(版本>=0.3.6) 66 | * WXWORK_ROBOT_MSG_ID : 消息ID(版本>=0.3.6) 67 | * WXWORK_ROBOT_IMAGE_URL : 如果是图文混排和图片消息,这个指向消息内的图片地址(版本>=0.8.0) 68 | * WXWORK_ROBOT_GET_CHAT_INFO_URL : 可以用于获取消息信息的URL(版本>=0.3.9),有效期为5分钟,调用一次后失效 69 | * WXWORK_ROBOT_POST_ID : post id(版本>=0.9.0) 70 | * WXWORK_ROBOT_CHAT_ID : chat id(版本>=0.3.9),用于区分聊天群,如果机器人被添加到多个群,可以用这个指定主动发消息到哪个群 71 | * WXWORK_ROBOT_CHAT_TYPE : chat type(版本>=0.6.1),对应企业微信机器人消息的ChatType字段(会话类型,single/group,分别表示:单聊\群聊话) 72 | * WXWORK_ROBOT_HTTP_RESPONSE : HTTP回包(仅 ```type``` 为 http 时的echo字段可用) 73 | * WXWORK_ROBOT_MSG_TYPE : msg type(版本>=0.7.0),对应企业微信机器人消息的MsgType字段(text/event/attachment) 74 | * WXWORK_ROBOT_APP_VERSION : msg type(版本>=0.7.0),对应企业微信机器人消息的AppVersion字段 75 | * WXWORK_ROBOT_EVENT_TYPE : msg type(版本>=0.7.0),对应企业微信机器人消息的EventType字段(目前可能是add_to_chat表示被添加进群,或者delete_from_chat表示被移出群, enter_chat 表示用户进入机器人单聊) 76 | * WXWORK_ROBOT_ACTION_NAME : msg type(版本>=0.7.0),对应企业微信机器人消息的Actions.Name字段(用户点击按钮的名字) 77 | * WXWORK_ROBOT_ACTION_VALUE : msg type(版本>=0.7.0),对应企业微信机器人消息的Actions.Value字段(用户点击按钮的值) 78 | * WXWORK_ROBOT_ACTION_CALLBACKID : msg type(版本>=0.7.0),对应企业微信机器人消息的Attachment.CallbackId字段(attachment中设置的回调id) 79 | 80 | 81 | ## 配置说明 82 | 83 | 注意,下面只是配置示例,实际使用的配置必须是标准json,不支持注释 84 | 85 | ```javascript 86 | { 87 | "listen": ["0.0.0.0:12019", ":::12019"], // 监听列表,这里配置了ipv4和ipv6地址 88 | "task_timeout": 4000, // 超时时间4000ms,企业微信要求在5秒内回应,这里容忍1秒钟的网络延迟 89 | "workers": 8, // 工作线程数 90 | "backlog": 256, // 建立连接的排队长度 91 | "keep_alive": 5, // tcp保持连接的心跳间隔(秒) (版本: >=0.6.0) 92 | "client_timeout": 5000, // 客户端第一个请求的超时时间(毫秒) (版本: >=0.6.0) 93 | "client_shutdown": 5000, // 客户端连接的超时时间(毫秒) (版本: >=0.6.0) 94 | "max_connection_per_worker": 20480, // 每个worker的最大连接数,当连接数满之后不会再接受新连接 (版本: >=0.6.0) 95 | "max_concurrent_rate_per_worker": 256, // 每个worker的最大握手连接数,当连接数满之后不会再接受新连接(一般用于控制SSL握手的开销) (版本: >=0.6.0) 96 | "payload_size_limit": 262144, // 消息体最大长度,默认: 262144(256KB) (版本: >=0.6.0) 97 | "static_root": ".", // 静态文件服务根目录,默认不开启 (版本: >=0.10.1) 98 | "cmds": { // 这里所有的command所有的project共享 99 | "default": { // 如果找不到命令,会尝试找名称为default的命令执行,这时候 100 | "type": "echo", // 直接输出类型的命令 101 | "echo": "我还不认识这个指令呐!({{WXWORK_ROBOT_CMD}})", // 输出内容 102 | "order": 999, // 命令匹配优先级,越小则越优先匹配,默认为 0 103 | "hidden": true // 是否隐藏,所有的命令都有这个选项,用户help命令隐藏这条指令的帮助信息 104 | }, 105 | "": { // 如果输入了空消息或者attachment消息,则会匹配这个命令而不是default,没有配置空命令则会直接忽略输入 106 | "type": "echo", 107 | "echo": "Hello, 本群会话ID: {{WXWORK_ROBOT_CHAT_ID}}", 108 | "order": 999, 109 | "hidden": true 110 | }, 111 | "(help)|(帮助)|(指令列表)": { 112 | "type": "help", // 帮助类型的命令 113 | "description": "help|帮助|指令列表", // 描述,所有的命令都有这个选项,用于help类型命令的输出,如果没有这一项,则会直接输出命令的key(匹配式) 114 | "prefix": "### 可用指令列表\r\n" // 帮助信息前缀 115 | "suffix": "" // 帮助信息后缀 116 | "case_insensitive": true, // [所有命令] 是否忽略大小写(默认:true) 117 | "multi_line": true, // [所有命令] 是否开启逐行匹配(默认:true,When enabled, ^ matches the beginning of lines and $ matches the end of lines.) 118 | "unicode": true, // [所有命令] 是否开启unicode支持(默认:true,When disabled, character classes such as \w only match ASCII word characters instead of all Unicode word characters) 119 | "octal": true, // [所有命令] 是否支持octal语法(默认:false) 120 | "dot_matches_new_line": false, // [所有命令] .是否匹配换行符(默认:true) 121 | "order": 0 // [所有命令] 命令匹配优先级,越小则越优先匹配(默认: 0) 122 | }, 123 | "说\\s*(?P[^\\r\\n]+)": { 124 | "type": "echo", 125 | "echo": "{{WXWORK_ROBOT_CMD_MSG}}", // 可以使用匹配式里的变量 126 | "description": "说**消息内容**", 127 | "order": 2 128 | }, 129 | "执行命令\\s*(?P[^\\s]+)\\s*(?P[^\\s]*)": { 130 | "type": "spawn", // 启动子进程执行命令,注意,任务超时并不会被kill掉 131 | "exec": "{{WXWORK_ROBOT_CMD_EXEC}}", 132 | "args": ["{{WXWORK_ROBOT_CMD_PARAM}}"], 133 | "cwd": "", 134 | "env": { // 命令级环境变量,所有的命令都有这个选项,这些环境变量仅此命令有效 135 | "TEST_ENV": "all env key will be WXWORK_ROBOT_CMD_{NAME IN ENV} or WXWORK_ROBOT_PROJECT_{NAME}" 136 | }, 137 | "description": "执行命令**可执行文件路径** ***参数***", 138 | "output_type": "输出类型", // markdown/text 139 | "order": 2 140 | } 141 | }, 142 | "events": { // 这里的事件所有project共享 143 | "add_to_chat": { // 加入群聊(内容和命令一样) 144 | "type": "echo", 145 | "echo": "Hi, 大家好" 146 | }, 147 | "enter_chat": { // 加入单聊(内容和命令一样) 148 | "type": "echo", 149 | "echo": "Hi, {{WXWORK_ROBOT_MSG_FROM_NAME}}" 150 | } 151 | }, 152 | "projects": [{ // 项目列表,可以每个项目对应一个机器人,也可以多个机器人共享一个项目 153 | "name": "test_proj", // 名称,影响机器人回调路径,比如说这里的配置就是: http://外网IP:/12019/test_proj/ 154 | "token": "hJqcu3uJ9Tn2gXPmxx2w9kkCkCE2EPYo", // 对应机器人里配置的Token 155 | "encodingAESKey": "6qkdMrq68nTKduznJYO1A37W2oEgpkMUvkttRToqhUt", // 对应机器人里配置的EncodingAESKey 156 | "env": { // 项目级环境变量,这些环境变量仅此项目有效 157 | "testURL": "robots.txt" 158 | }, 159 | "cmds": { // 项目级命令,这些命令仅此项目有效 160 | "http请求": { 161 | "type": "http", // http请求类命令 162 | "method": "get", // http方法,可选值为 get/post/put/delete/head,如果不填则会自动从设置,如果post里有数据则会自动设为post,否则自动设为get 163 | "url": "https://owent.net/{{WXWORK_ROBOT_PROJECT_TEST_URL}}", // http请求地址 164 | "post": "", // body里的数据 165 | "content_type": "", // content-type,可不填 166 | "headers": { // 请求的额外header 167 | "X-TEST": "value" 168 | }, 169 | "echo": "已发起HTTP请求,回包内容\r\n{{WXWORK_ROBOT_HTTP_RESPONSE}}" // 机器人回应内容 170 | }, 171 | "访问\\s*(?P[^\\r\\n]+)": { 172 | "type": "http", 173 | "url": "{{WXWORK_ROBOT_CMD_URL}}", 174 | "post": "", 175 | "echo": "HTTP请求: {{WXWORK_ROBOT_CMD_URL}}\r\n{{WXWORK_ROBOT_HTTP_RESPONSE}}", 176 | "description": "访问**URL地址**" 177 | } 178 | }, 179 | "events": { // 这里的事件仅当前project有效 180 | "delete_from_chat": { // 离开群聊 181 | "type": "echo", 182 | "echo": "再见" 183 | } 184 | } 185 | }] 186 | } 187 | ``` 188 | 189 | ## 语法相关 190 | 191 | + 完整示例配置见 [etc/conf.json](etc/conf.json) 192 | > 请确保 taskTimeout 字段低于5000毫秒,因为企业微信的超时是5秒,如果加上网络延迟之后机器人回包略多于5s企业微信会无回包 193 | 194 | + 配置参数模板语法见: [handlebars][1] 195 | + 正则表示语法见: [regex][2] 196 | 197 | ## Developer 198 | 199 | 1. 下载rust编译环境( https://www.rust-lang.org ) 200 | > 在一些发行版或者软件仓库中也可以通过 pacman/apt/yum/choco 等安装 rust 目标 201 | 2. 升级rust工具链 ```rustup self update && rustup update``` 202 | 3. 安装一个编译目标(默认也会安装一个的) ```rustup target install <目标架构>``` 203 | > 可以通过 ```rustup target list``` 来查看支持的架构 204 | 4. 克隆仓库并进入主目录 205 | 5. 运行编译命令: ```cargo build``` 206 | 207 | 更多详情见: https://rustup.rs/ 208 | 209 | ## LICENSE 210 | 211 | [MIT](LICENSE-MIT) or [Apache License - 2.0](LICENSE) 212 | 213 | [1]: https://crates.io/crates/handlebars 214 | [2]: https://docs.rs/regex/ 215 | -------------------------------------------------------------------------------- /src/wxwork_robot/base64.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | use std::iter::FromIterator; 3 | 4 | #[derive(Clone, Copy)] 5 | pub struct Engine<'a, 'b> { 6 | encode_map: &'a [char; 64], 7 | decode_map: &'b [u8; 128], 8 | padding_char: char, 9 | } 10 | 11 | impl<'a, 'b> fmt::Debug for Engine<'a, 'b> { 12 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 13 | write!( 14 | f, 15 | "base64::Engine\n\tencode_map: {}\n\tdecode_map: {}\n\tpadding_char: {}", 16 | String::from_iter(self.encode_map.iter()), 17 | hex::encode(self.decode_map.as_ref()), 18 | if self.padding_char == '\0' { 19 | '-' 20 | } else { 21 | self.padding_char 22 | } 23 | ) 24 | } 25 | } 26 | 27 | /// Errors that can occur while decoding. 28 | #[derive(Clone, Debug)] 29 | pub struct DecodeError { 30 | pub message: String, 31 | pub position: usize, 32 | } 33 | 34 | impl ToString for DecodeError { 35 | fn to_string(&self) -> String { 36 | self.message.clone() 37 | } 38 | } 39 | 40 | /// Errors that can occur while encoding. 41 | #[derive(Clone, Debug)] 42 | pub struct EncodeError { 43 | pub message: String, 44 | pub position: usize, 45 | } 46 | 47 | impl ToString for EncodeError { 48 | fn to_string(&self) -> String { 49 | self.message.clone() 50 | } 51 | } 52 | 53 | impl<'a, 'b> Engine<'a, 'b> { 54 | #[allow(non_snake_case, dead_code)] 55 | pub fn encode>(&self, input: &T) -> Result { 56 | let input_bytes = input.as_ref(); 57 | let input_len: usize = input_bytes.len(); 58 | if input_len == 0 { 59 | return Ok(String::default()); 60 | } 61 | 62 | let mut n = (input_len + 2) / 3; 63 | if n > ((usize::max_value() - 1) / 4) { 64 | return Err(EncodeError { 65 | message: String::from("buffer too large"), 66 | position: 0, 67 | }); 68 | } 69 | 70 | n *= 4; 71 | 72 | // no padding 73 | if self.padding_char == '\0' { 74 | let nopadding = input_len % 3; 75 | if 0 != nopadding { 76 | n -= 3 - nopadding; 77 | } 78 | } 79 | 80 | let mut ret = String::with_capacity(n); 81 | for i in 0..(input_len / 3) { 82 | let start_pos = i * 3; 83 | let C1 = input_bytes[start_pos]; 84 | let C2 = input_bytes[start_pos + 1]; 85 | let C3 = input_bytes[start_pos + 2]; 86 | 87 | ret.push(self.encode_map[((C1 >> 2) & 0x3F) as usize]); 88 | ret.push(self.encode_map[((((C1 & 3) << 4) + (C2 >> 4)) & 0x3F) as usize]); 89 | ret.push(self.encode_map[((((C2 & 15) << 2) + (C3 >> 6)) & 0x3F) as usize]); 90 | ret.push(self.encode_map[(C3 & 0x3F) as usize]); 91 | } 92 | 93 | let tail_pos = (input_len / 3) * 3; 94 | if tail_pos < input_len { 95 | let C1 = input_bytes[tail_pos]; 96 | let C2 = if tail_pos + 1 < input_len { 97 | input_bytes[tail_pos + 1] 98 | } else { 99 | 0 100 | }; 101 | 102 | ret.push(self.encode_map[((C1 >> 2) & 0x3F) as usize]); 103 | ret.push(self.encode_map[((((C1 & 3) << 4) + (C2 >> 4)) & 0x3F) as usize]); 104 | 105 | if (tail_pos + 1) < input_len { 106 | ret.push(self.encode_map[(((C2 & 15) << 2) & 0x3F) as usize]); 107 | } else if self.padding_char != '\0' { 108 | ret.push(self.padding_char); 109 | } 110 | 111 | if self.padding_char != '\0' { 112 | ret.push(self.padding_char); 113 | } 114 | } 115 | 116 | Ok(ret) 117 | } 118 | 119 | #[allow(non_snake_case, dead_code)] 120 | pub fn decode>(&self, input: &T) -> Result, DecodeError> { 121 | let input_bytes = input.as_ref(); 122 | let input_len: usize = input_bytes.len(); 123 | let mut real_len = 0; 124 | if input_len == 0 { 125 | return Ok(Vec::new()); 126 | } 127 | 128 | /* First pass: check for validity and get output length */ 129 | for c in input_bytes.iter().take(input_len) { 130 | // skip space 131 | let C1 = (*c) as char; 132 | if C1 == ' ' || C1 == '\t' || C1 == '\r' || C1 == '\n' { 133 | continue; 134 | } 135 | 136 | real_len += 1; 137 | } 138 | 139 | let mut ret: Vec = Vec::with_capacity(((real_len + 3) / 4) * 3); 140 | let mut x: u32 = 0; 141 | let mut n: usize = 0; 142 | let mut block_len: i32 = 3; 143 | 144 | for (i, c) in input_bytes.iter().enumerate().take(input_len) { 145 | // skip space 146 | let C1 = (*c) as char; 147 | if C1 == ' ' || C1 == '\t' || C1 == '\r' || C1 == '\n' { 148 | continue; 149 | } 150 | 151 | let C1IDX = C1 as usize; 152 | if C1IDX > 127 { 153 | return Err(DecodeError { 154 | message: format!("Charector at {} is invalid, not ascii", i), 155 | position: i, 156 | }); 157 | } 158 | 159 | if C1 != '\0' && C1 == self.padding_char { 160 | block_len -= 1; 161 | if block_len < 1 { 162 | return Err(DecodeError { 163 | message: format!("There are too many padding charector at {}", i), 164 | position: i, 165 | }); 166 | } 167 | } else if self.decode_map[C1IDX] == 127 { 168 | return Err(DecodeError { 169 | message: format!("Charector at {} is invalid, unknown charector", i), 170 | position: i, 171 | }); 172 | } 173 | 174 | x = (x << 6) | ((self.decode_map[C1IDX] & 0x3F) as u32); 175 | 176 | n += 1; 177 | if n == 4 { 178 | n = 0; 179 | if block_len > 0 { 180 | ret.push(((x >> 16) & 0xFF) as u8); 181 | } 182 | 183 | if block_len > 1 { 184 | ret.push(((x >> 8) & 0xFF) as u8); 185 | } 186 | 187 | if block_len > 2 { 188 | ret.push((x & 0xFF) as u8); 189 | } 190 | } 191 | } 192 | 193 | // no padding, the tail code 194 | if n == 2 { 195 | ret.push(((x >> 4) & 0xFF) as u8); 196 | } else if n == 3 { 197 | ret.push(((x >> 10) & 0xFF) as u8); 198 | ret.push(((x >> 2) & 0xFF) as u8); 199 | } 200 | 201 | Ok(ret) 202 | } 203 | } 204 | 205 | #[allow(dead_code)] 206 | pub const STANDARD: Engine = Engine { 207 | encode_map: &[ 208 | 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 209 | 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 210 | 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '0', '1', 211 | '2', '3', '4', '5', '6', '7', '8', '9', '+', '/', 212 | ], 213 | decode_map: &[ 214 | 127, 127, 127, 127, 127, 127, 127, 127, 127, 127, 127, 127, 127, 127, 127, 127, 127, 127, 215 | 127, 127, 127, 127, 127, 127, 127, 127, 127, 127, 127, 127, 127, 127, 127, 127, 127, 127, 216 | 127, 127, 127, 127, 127, 127, 127, 62, 127, 127, 127, 63, 52, 53, 54, 55, 56, 57, 58, 59, 217 | 60, 61, 127, 127, 127, 127, 127, 127, 127, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 218 | 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 127, 127, 127, 127, 127, 127, 26, 27, 28, 219 | 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 220 | 127, 127, 127, 127, 127, 221 | ], 222 | padding_char: '=', 223 | }; 224 | 225 | #[allow(dead_code)] 226 | pub const STANDARD_UTF7: Engine = Engine { 227 | encode_map: STANDARD.encode_map, 228 | decode_map: STANDARD.decode_map, 229 | padding_char: '\0', 230 | }; 231 | 232 | #[cfg(test)] 233 | mod tests { 234 | 235 | use super::*; 236 | 237 | const BASE64_TEST_DEC: &[u8; 64] = &[ 238 | 0x24u8, 0x48, 0x6E, 0x56, 0x87, 0x62, 0x5A, 0xBD, 0xBF, 0x17, 0xD9, 0xA2, 0xC4, 0x17, 0x1A, 239 | 0x01, 0x94, 0xED, 0x8F, 0x1E, 0x11, 0xB3, 0xD7, 0x09, 0x0C, 0xB6, 0xE9, 0x10, 0x6F, 0x22, 240 | 0xEE, 0x13, 0xCA, 0xB3, 0x07, 0x05, 0x76, 0xC9, 0xFA, 0x31, 0x6C, 0x08, 0x34, 0xFF, 0x8D, 241 | 0xC2, 0x6C, 0x38, 0x00, 0x43, 0xE9, 0x54, 0x97, 0xAF, 0x50, 0x4B, 0xD1, 0x41, 0xBA, 0x95, 242 | 0x31, 0x5A, 0x0B, 0x97, 243 | ]; 244 | 245 | const BASE64_TEST_ENC_STANDARD: &str = 246 | "JEhuVodiWr2/F9mixBcaAZTtjx4Rs9cJDLbpEG8i7hPKswcFdsn6MWwINP+Nwmw4AEPpVJevUEvRQbqVMVoLlw=="; 247 | 248 | const BASE64_TEST_ENC_UTF7: &str = 249 | "JEhuVodiWr2/F9mixBcaAZTtjx4Rs9cJDLbpEG8i7hPKswcFdsn6MWwINP+Nwmw4AEPpVJevUEvRQbqVMVoLlw"; 250 | 251 | #[test] 252 | fn encode_standard() { 253 | assert_eq!( 254 | STANDARD.encode(BASE64_TEST_DEC.as_ref()).unwrap(), 255 | BASE64_TEST_ENC_STANDARD 256 | ); 257 | } 258 | 259 | #[test] 260 | fn decode_standard() { 261 | let dec = STANDARD 262 | .decode(BASE64_TEST_ENC_STANDARD.as_bytes()) 263 | .unwrap(); 264 | assert_eq!(hex::encode(&dec), hex::encode(BASE64_TEST_DEC.as_ref())); 265 | } 266 | 267 | #[test] 268 | fn encode_utf7() { 269 | assert_eq!( 270 | STANDARD_UTF7.encode(BASE64_TEST_DEC.as_ref()).unwrap(), 271 | BASE64_TEST_ENC_UTF7 272 | ); 273 | } 274 | 275 | #[test] 276 | fn decode_utf7() { 277 | let dec = STANDARD_UTF7 278 | .decode(BASE64_TEST_ENC_UTF7.as_bytes()) 279 | .unwrap(); 280 | assert_eq!(hex::encode(&dec), hex::encode(BASE64_TEST_DEC.as_ref())); 281 | } 282 | 283 | #[test] 284 | fn encode_standard_nopading() { 285 | assert_eq!( 286 | STANDARD_UTF7.encode("any carnal pleas".as_bytes()).unwrap(), 287 | "YW55IGNhcm5hbCBwbGVhcw" 288 | ); 289 | assert_eq!( 290 | STANDARD_UTF7 291 | .encode("any carnal pleasu".as_bytes()) 292 | .unwrap(), 293 | "YW55IGNhcm5hbCBwbGVhc3U" 294 | ); 295 | assert_eq!( 296 | STANDARD_UTF7 297 | .encode("any carnal pleasur".as_bytes()) 298 | .unwrap(), 299 | "YW55IGNhcm5hbCBwbGVhc3Vy" 300 | ); 301 | } 302 | 303 | #[test] 304 | fn decode_standard_nopading() { 305 | assert_eq!( 306 | String::from_utf8( 307 | STANDARD_UTF7 308 | .decode("YW55IGNhcm5hbCBwbGVhcw".as_bytes()) 309 | .unwrap() 310 | ) 311 | .unwrap(), 312 | "any carnal pleas" 313 | ); 314 | 315 | assert_eq!( 316 | String::from_utf8( 317 | STANDARD_UTF7 318 | .decode("YW55IGNhcm5hbCBwbGVhc3U".as_bytes()) 319 | .unwrap() 320 | ) 321 | .unwrap(), 322 | "any carnal pleasu" 323 | ); 324 | 325 | assert_eq!( 326 | String::from_utf8( 327 | STANDARD_UTF7 328 | .decode("YW55IGNhcm5hbCBwbGVhc3Vy".as_bytes()) 329 | .unwrap() 330 | ) 331 | .unwrap(), 332 | "any carnal pleasur" 333 | ); 334 | } 335 | } 336 | -------------------------------------------------------------------------------- /tools/pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | # Specify a configuration file. 4 | #rcfile= 5 | 6 | # Python code to execute, usually for sys.path manipulation such as 7 | # pygtk.require(). 8 | #init-hook= 9 | 10 | # Add files or directories to the blacklist. They should be base names, not 11 | # paths. 12 | ignore=CVS 13 | 14 | # Add files or directories matching the regex patterns to the blacklist. The 15 | # regex matches against base names, not paths. 16 | ignore-patterns= 17 | 18 | # Pickle collected data for later comparisons. 19 | persistent=yes 20 | 21 | # List of plugins (as comma separated values of python modules names) to load, 22 | # usually to register additional checkers. 23 | load-plugins= 24 | 25 | # Use multiple processes to speed up Pylint. 26 | jobs=1 27 | 28 | # Allow loading of arbitrary C extensions. Extensions are imported into the 29 | # active Python interpreter and may run arbitrary code. 30 | unsafe-load-any-extension=no 31 | 32 | # A comma-separated list of package or module names from where C extensions may 33 | # be loaded. Extensions are loading into the active Python interpreter and may 34 | # run arbitrary code 35 | extension-pkg-whitelist= 36 | 37 | # Allow optimization of some AST trees. This will activate a peephole AST 38 | # optimizer, which will apply various small optimizations. For instance, it can 39 | # be used to obtain the result of joining multiple strings with the addition 40 | # operator. Joining a lot of strings can lead to a maximum recursion error in 41 | # Pylint and this flag can prevent that. It has one side effect, the resulting 42 | # AST will be different than the one from reality. This option is deprecated 43 | # and it will be removed in Pylint 2.0. 44 | optimize-ast=no 45 | 46 | 47 | [MESSAGES CONTROL] 48 | 49 | # Only show warnings with the listed confidence levels. Leave empty to show 50 | # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED 51 | confidence= 52 | 53 | # Enable the message, report, category or checker with the given id(s). You can 54 | # either give multiple identifier separated by comma (,) or put this option 55 | # multiple time (only on the command line, not in the configuration file where 56 | # it should appear only once). See also the "--disable" option for examples. 57 | #enable= 58 | 59 | # Disable the message, report, category or checker with the given id(s). You 60 | # can either give multiple identifiers separated by comma (,) or put this 61 | # option multiple times (only on the command line, not in the configuration 62 | # file where it should appear only once).You can also use "--disable=all" to 63 | # disable everything first and then reenable specific checks. For example, if 64 | # you want to run only the similarities checker, you can use "--disable=all 65 | # --enable=similarities". If you want to run only the classes checker, but have 66 | # no Warning level messages displayed, use"--disable=all --enable=classes 67 | # --disable=W" 68 | disable=print-statement,parameter-unpacking,unpacking-in-except,old-raise-syntax,backtick,import-star-module-level,apply-builtin,basestring-builtin,buffer-builtin,cmp-builtin,coerce-builtin,execfile-builtin,file-builtin,long-builtin,raw_input-builtin,reduce-builtin,standarderror-builtin,unicode-builtin,xrange-builtin,coerce-method,delslice-method,getslice-method,setslice-method,no-absolute-import,old-division,dict-iter-method,dict-view-method,next-method-called,metaclass-assignment,indexing-exception,raising-string,reload-builtin,oct-method,hex-method,nonzero-method,cmp-method,input-builtin,round-builtin,intern-builtin,unichr-builtin,map-builtin-not-iterating,zip-builtin-not-iterating,range-builtin-not-iterating,filter-builtin-not-iterating,using-cmp-argument,long-suffix,old-ne-operator,old-octal-literal,suppressed-message,useless-suppression,c0111,w0703,c0103,w0613 69 | 70 | 71 | [REPORTS] 72 | 73 | # Set the output format. Available formats are text, parseable, colorized, msvs 74 | # (visual studio) and html. You can also give a reporter class, eg 75 | # mypackage.mymodule.MyReporterClass. 76 | output-format=text 77 | 78 | # Put messages in a separate file for each module / package specified on the 79 | # command line instead of printing them on stdout. Reports (if any) will be 80 | # written in a file name "pylint_global.[txt|html]". This option is deprecated 81 | # and it will be removed in Pylint 2.0. 82 | files-output=no 83 | 84 | # Tells whether to display a full report or only the messages 85 | reports=yes 86 | 87 | # Python expression which should return a note less than 10 (10 is the highest 88 | # note). You have access to the variables errors warning, statement which 89 | # respectively contain the number of errors / warnings messages and the total 90 | # number of statements analyzed. This is used by the global evaluation report 91 | # (RP0004). 92 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 93 | 94 | # Template used to display messages. This is a python new-style format string 95 | # used to format the message information. See doc for all details 96 | #msg-template= 97 | 98 | 99 | [BASIC] 100 | 101 | # Good variable names which should always be accepted, separated by a comma 102 | good-names=i,j,k,ex,Run,_ 103 | 104 | # Bad variable names which should always be refused, separated by a comma 105 | bad-names=foo,bar,baz,toto,tutu,tata 106 | 107 | # Colon-delimited sets of names that determine each other's naming style when 108 | # the name regexes allow several styles. 109 | name-group= 110 | 111 | # Include a hint for the correct naming format with invalid-name 112 | include-naming-hint=no 113 | 114 | # List of decorators that produce properties, such as abc.abstractproperty. Add 115 | # to this list to register other decorators that produce valid properties. 116 | property-classes=abc.abstractproperty 117 | 118 | # Regular expression matching correct module names 119 | module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 120 | 121 | # Naming hint for module names 122 | module-name-hint=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 123 | 124 | # Regular expression matching correct constant names 125 | const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ 126 | 127 | # Naming hint for constant names 128 | const-name-hint=(([A-Z_][A-Z0-9_]*)|(__.*__))$ 129 | 130 | # Regular expression matching correct class names 131 | class-rgx=[A-Z_][a-zA-Z0-9]+$ 132 | 133 | # Naming hint for class names 134 | class-name-hint=[A-Z_][a-zA-Z0-9]+$ 135 | 136 | # Regular expression matching correct function names 137 | function-rgx=[a-z_][a-z0-9_]{2,30}$ 138 | 139 | # Naming hint for function names 140 | function-name-hint=[a-z_][a-z0-9_]{2,30}$ 141 | 142 | # Regular expression matching correct method names 143 | method-rgx=[a-z_][a-z0-9_]{2,30}$ 144 | 145 | # Naming hint for method names 146 | method-name-hint=[a-z_][a-z0-9_]{2,30}$ 147 | 148 | # Regular expression matching correct attribute names 149 | attr-rgx=[a-z_][a-z0-9_]{2,30}$ 150 | 151 | # Naming hint for attribute names 152 | attr-name-hint=[a-z_][a-z0-9_]{2,30}$ 153 | 154 | # Regular expression matching correct argument names 155 | argument-rgx=[a-z_][a-z0-9_]{2,30}$ 156 | 157 | # Naming hint for argument names 158 | argument-name-hint=[a-z_][a-z0-9_]{2,30}$ 159 | 160 | # Regular expression matching correct variable names 161 | variable-rgx=[a-z_][a-z0-9_]{2,30}$ 162 | 163 | # Naming hint for variable names 164 | variable-name-hint=[a-z_][a-z0-9_]{2,30}$ 165 | 166 | # Regular expression matching correct class attribute names 167 | class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ 168 | 169 | # Naming hint for class attribute names 170 | class-attribute-name-hint=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ 171 | 172 | # Regular expression matching correct inline iteration names 173 | inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ 174 | 175 | # Naming hint for inline iteration names 176 | inlinevar-name-hint=[A-Za-z_][A-Za-z0-9_]*$ 177 | 178 | # Regular expression which should only match function or class names that do 179 | # not require a docstring. 180 | # no-docstring-rgx=^_ 181 | no-docstring-rgx=. 182 | 183 | # Minimum line length for functions/classes that require docstrings, shorter 184 | # ones are exempt. 185 | docstring-min-length=-1 186 | 187 | 188 | [ELIF] 189 | 190 | # Maximum number of nested blocks for function / method body 191 | max-nested-blocks=5 192 | 193 | 194 | [FORMAT] 195 | 196 | # Maximum number of characters on a single line. 197 | max-line-length=180 198 | 199 | # Regexp for a line that is allowed to be longer than the limit. 200 | ignore-long-lines=^\s*(# )??$ 201 | 202 | # Allow the body of an if to be on the same line as the test if there is no 203 | # else. 204 | single-line-if-stmt=no 205 | 206 | # List of optional constructs for which whitespace checking is disabled. `dict- 207 | # separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. 208 | # `trailing-comma` allows a space between comma and closing bracket: (a, ). 209 | # `empty-line` allows space-only lines. 210 | no-space-check=trailing-comma,dict-separator 211 | 212 | # Maximum number of lines in a module 213 | max-module-lines=3000 214 | 215 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 216 | # tab). 217 | indent-string=' ' 218 | 219 | # Number of spaces of indent required inside a hanging or continued line. 220 | indent-after-paren=4 221 | 222 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 223 | expected-line-ending-format= 224 | 225 | 226 | [LOGGING] 227 | 228 | # Logging modules to check that the string format arguments are in logging 229 | # function parameter format 230 | logging-modules=logging 231 | 232 | 233 | [MISCELLANEOUS] 234 | 235 | # List of note tags to take in consideration, separated by a comma. 236 | notes=FIXME,XXX,TODO 237 | 238 | 239 | [SIMILARITIES] 240 | 241 | # Minimum lines number of a similarity. 242 | min-similarity-lines=4 243 | 244 | # Ignore comments when computing similarities. 245 | ignore-comments=yes 246 | 247 | # Ignore docstrings when computing similarities. 248 | ignore-docstrings=yes 249 | 250 | # Ignore imports when computing similarities. 251 | ignore-imports=no 252 | 253 | 254 | [SPELLING] 255 | 256 | # Spelling dictionary name. Available dictionaries: none. To make it working 257 | # install python-enchant package. 258 | spelling-dict= 259 | 260 | # List of comma separated words that should not be checked. 261 | spelling-ignore-words= 262 | 263 | # A path to a file that contains private dictionary; one word per line. 264 | spelling-private-dict-file= 265 | 266 | # Tells whether to store unknown words to indicated private dictionary in 267 | # --spelling-private-dict-file option instead of raising a message. 268 | spelling-store-unknown-words=no 269 | 270 | 271 | [TYPECHECK] 272 | 273 | # Tells whether missing members accessed in mixin class should be ignored. A 274 | # mixin class is detected if its name ends with "mixin" (case insensitive). 275 | ignore-mixin-members=yes 276 | 277 | # List of module names for which member attributes should not be checked 278 | # (useful for modules/projects where namespaces are manipulated during runtime 279 | # and thus existing member attributes cannot be deduced by static analysis. It 280 | # supports qualified module names, as well as Unix pattern matching. 281 | ignored-modules= 282 | 283 | # List of class names for which member attributes should not be checked (useful 284 | # for classes with dynamically set attributes). This supports the use of 285 | # qualified names. 286 | ignored-classes=optparse.Values,thread._local,_thread._local 287 | 288 | # List of members which are set dynamically and missed by pylint inference 289 | # system, and so shouldn't trigger E1101 when accessed. Python regular 290 | # expressions are accepted. 291 | generated-members= 292 | 293 | # List of decorators that produce context managers, such as 294 | # contextlib.contextmanager. Add to this list to register other decorators that 295 | # produce valid context managers. 296 | contextmanager-decorators=contextlib.contextmanager 297 | 298 | 299 | [VARIABLES] 300 | 301 | # Tells whether we should check for unused import in __init__ files. 302 | init-import=no 303 | 304 | # A regular expression matching the name of dummy variables (i.e. expectedly 305 | # not used). 306 | dummy-variables-rgx=(_+[a-zA-Z0-9]*?$)|dummy 307 | 308 | # List of additional names supposed to be defined in builtins. Remember that 309 | # you should avoid to define new builtins when possible. 310 | additional-builtins= 311 | 312 | # List of strings which can identify a callback function by name. A callback 313 | # name must start or end with one of those strings. 314 | callbacks=cb_,_cb 315 | 316 | # List of qualified module names which can have objects that can redefine 317 | # builtins. 318 | redefining-builtins-modules=six.moves,future.builtins 319 | 320 | 321 | [CLASSES] 322 | 323 | # List of method names used to declare (i.e. assign) instance attributes. 324 | defining-attr-methods=__init__,__new__,setUp 325 | 326 | # List of valid names for the first argument in a class method. 327 | valid-classmethod-first-arg=cls 328 | 329 | # List of valid names for the first argument in a metaclass class method. 330 | valid-metaclass-classmethod-first-arg=mcs 331 | 332 | # List of member names, which should be excluded from the protected access 333 | # warning. 334 | exclude-protected=_asdict,_fields,_replace,_source,_make 335 | 336 | 337 | [DESIGN] 338 | 339 | # Maximum number of arguments for function / method 340 | max-args=5 341 | 342 | # Argument names that match this expression will be ignored. Default to name 343 | # with leading underscore 344 | ignored-argument-names=_.* 345 | 346 | # Maximum number of locals for function / method body 347 | max-locals=15 348 | 349 | # Maximum number of return / yield for function / method body 350 | max-returns=6 351 | 352 | # Maximum number of branch for function / method body 353 | max-branches=12 354 | 355 | # Maximum number of statements in function / method body 356 | max-statements=50 357 | 358 | # Maximum number of parents for a class (see R0901). 359 | max-parents=7 360 | 361 | # Maximum number of attributes for a class (see R0902). 362 | max-attributes=7 363 | 364 | # Minimum number of public methods for a class (see R0903). 365 | min-public-methods=2 366 | 367 | # Maximum number of public methods for a class (see R0904). 368 | max-public-methods=20 369 | 370 | # Maximum number of boolean expressions in a if statement 371 | max-bool-expr=5 372 | 373 | 374 | [IMPORTS] 375 | 376 | # Deprecated modules which should not be used, separated by a comma 377 | deprecated-modules=optparse 378 | 379 | # Create a graph of every (i.e. internal and external) dependencies in the 380 | # given file (report RP0402 must not be disabled) 381 | import-graph= 382 | 383 | # Create a graph of external dependencies in the given file (report RP0402 must 384 | # not be disabled) 385 | ext-import-graph= 386 | 387 | # Create a graph of internal dependencies in the given file (report RP0402 must 388 | # not be disabled) 389 | int-import-graph= 390 | 391 | # Force import order to recognize a module as part of the standard 392 | # compatibility libraries. 393 | known-standard-library= 394 | 395 | # Force import order to recognize a module as part of a third party library. 396 | known-third-party=enchant 397 | 398 | # Analyse import fallback blocks. This can be used to support both Python 2 and 399 | # 3 compatible code, which means that the block might have code that exists 400 | # only in one or another interpreter, leading to false positives when analysed. 401 | analyse-fallback-blocks=no 402 | 403 | 404 | [EXCEPTIONS] 405 | 406 | # Exceptions that will emit a warning when being caught. Defaults to 407 | # "Exception" 408 | overgeneral-exceptions=Exception 409 | -------------------------------------------------------------------------------- /src/handles/robot.rs: -------------------------------------------------------------------------------- 1 | use crate::actix_web::{web, FromRequest, HttpRequest, HttpResponse}; 2 | use crate::serde::Deserialize; 3 | 4 | use std::sync::Arc; 5 | 6 | use super::super::app::AppEnvironment; 7 | use super::super::wxwork_robot::command_runtime; 8 | use super::super::wxwork_robot::message; 9 | 10 | #[derive(Deserialize)] 11 | pub struct WxWorkRobotVerifyMessage { 12 | msg_signature: String, 13 | timestamp: String, 14 | nonce: String, 15 | echostr: String, 16 | } 17 | 18 | #[derive(Deserialize)] 19 | pub struct WxWorkRobotPostMessage { 20 | msg_signature: String, 21 | timestamp: String, 22 | nonce: String, 23 | } 24 | 25 | #[allow(unused_parens)] 26 | pub async fn get_robot_project_name(_app: &AppEnvironment, req: &HttpRequest) -> Option { 27 | let params = web::Path::<(String)>::extract(req).await; 28 | if let Ok(project_name) = params { 29 | Some(project_name.into_inner()) 30 | } else { 31 | None 32 | } 33 | } 34 | 35 | fn make_robot_error_response_future(msg: &str) -> HttpResponse { 36 | message::make_robot_error_response_content(msg) 37 | } 38 | 39 | pub async fn dispatch_robot_request( 40 | app: AppEnvironment, 41 | req: HttpRequest, 42 | body: web::Bytes, 43 | ) -> HttpResponse { 44 | let project_name = if let Some(x) = get_robot_project_name(&app, &req).await { 45 | x 46 | } else { 47 | return make_robot_error_response_future("project not found"); 48 | }; 49 | 50 | if let Ok(x) = web::Query::::from_query(req.query_string()) { 51 | let xv = x.into_inner(); 52 | if !xv.echostr.is_empty() { 53 | return dispatch_robot_verify(app, project_name, xv); 54 | } 55 | } 56 | if let Ok(x) = web::Query::::from_query(req.query_string()) { 57 | return dispatch_robot_message(app, Arc::new(project_name), x.into_inner(), body).await; 58 | } 59 | 60 | make_robot_error_response_future("parameter error.") 61 | } 62 | 63 | fn dispatch_robot_verify( 64 | app: AppEnvironment, 65 | project_name: String, 66 | req_msg: WxWorkRobotVerifyMessage, 67 | ) -> HttpResponse { 68 | // GET http://api.3dept.com/?msg_signature=ASDFQWEXZCVAQFASDFASDFSS×tamp=13500001234&nonce=123412323&echostr=ENCRYPT_STR 69 | let proj_obj = if let Some(v) = app.get_project(project_name.as_str()) { 70 | v 71 | } else { 72 | return make_robot_error_response_future( 73 | format!("project \"{}\" not found", project_name).as_str(), 74 | ); 75 | }; 76 | 77 | if req_msg.msg_signature.is_empty() { 78 | return make_robot_error_response_future("msg_signature is required"); 79 | }; 80 | if req_msg.timestamp.is_empty() { 81 | return make_robot_error_response_future("timestamp is required"); 82 | }; 83 | if req_msg.nonce.is_empty() { 84 | return make_robot_error_response_future("nonce is required"); 85 | }; 86 | 87 | if !proj_obj.check_msg_signature( 88 | req_msg.msg_signature.as_str(), 89 | req_msg.timestamp.as_str(), 90 | req_msg.nonce.as_str(), 91 | req_msg.echostr.as_str(), 92 | ) { 93 | return make_robot_error_response_future( 94 | format!("project \"{}\" check msg_signature failed", project_name).as_str(), 95 | ); 96 | } 97 | 98 | info!( 99 | "project \"{}\" check msg_signature and passed", 100 | project_name 101 | ); 102 | 103 | let output = if let Ok(x) = proj_obj.decrypt_msg_raw_base64_content(req_msg.echostr.as_str()) { 104 | x 105 | } else { 106 | let err_msg = format!( 107 | "project \"{}\" try to decode \"{}\" failed", 108 | project_name, req_msg.echostr 109 | ); 110 | debug!("{}", err_msg); 111 | return make_robot_error_response_future(err_msg.as_str()); 112 | }; 113 | 114 | HttpResponse::Ok() 115 | .content_type("text/html; charset=utf-8") 116 | .body(output.content) 117 | } 118 | 119 | async fn dispatch_robot_message( 120 | app: AppEnvironment, 121 | project_name: Arc, 122 | req_msg: WxWorkRobotPostMessage, 123 | bytes: web::Bytes, 124 | ) -> HttpResponse { 125 | // POST http://api.3dept.com/?msg_signature=ASDFQWEXZCVAQFASDFASDFSS×tamp=13500001234&nonce=123412323 126 | if req_msg.msg_signature.is_empty() { 127 | return make_robot_error_response_future("msg_signature is required"); 128 | }; 129 | if req_msg.timestamp.is_empty() { 130 | return make_robot_error_response_future("timestamp is required"); 131 | }; 132 | if req_msg.nonce.is_empty() { 133 | return make_robot_error_response_future("nonce is required"); 134 | }; 135 | 136 | let proj_obj = if let Some(v) = app.get_project(project_name.as_str()) { 137 | v 138 | } else { 139 | return message::make_robot_error_response_content( 140 | format!("project \"{}\" not found", project_name).as_str(), 141 | ); 142 | }; 143 | 144 | debug!( 145 | "project \"{}\" try to decode {} bytes data: {}", 146 | project_name, 147 | bytes.len(), 148 | match String::from_utf8(bytes.to_vec()) { 149 | Ok(x) => x, 150 | Err(_) => hex::encode(&bytes), 151 | } 152 | ); 153 | let encrypt_msg_b64 = if let Some(x) = message::get_msg_encrypt_from_bytes(bytes) { 154 | x 155 | } else { 156 | return message::make_robot_error_response_content( 157 | format!("project \"{}\" can not decode message body", project_name).as_str(), 158 | ); 159 | }; 160 | 161 | if !proj_obj.check_msg_signature( 162 | req_msg.msg_signature.as_str(), 163 | req_msg.timestamp.as_str(), 164 | req_msg.nonce.as_str(), 165 | encrypt_msg_b64.as_str(), 166 | ) { 167 | return message::make_robot_error_response_content( 168 | format!( 169 | "project \"{}\" check msg_signature for message {} failed", 170 | project_name, encrypt_msg_b64 171 | ) 172 | .as_str(), 173 | ); 174 | } 175 | 176 | debug!( 177 | "project \"{}\" check msg_signature for message {} and passed", 178 | project_name, encrypt_msg_b64 179 | ); 180 | 181 | let msg_dec = if let Ok(x) = proj_obj.decrypt_msg_raw_base64_content(encrypt_msg_b64.as_str()) { 182 | x 183 | } else { 184 | return message::make_robot_error_response_content( 185 | format!( 186 | "project \"{}\" decrypt message {} failed", 187 | project_name, encrypt_msg_b64 188 | ) 189 | .as_str(), 190 | ); 191 | }; 192 | 193 | // 提取数据 194 | let msg_ntf = if let Some(x) = message::get_msg_from_str(msg_dec.content.as_str()) { 195 | x 196 | } else { 197 | return message::make_robot_error_response_content( 198 | format!( 199 | "project \"{}\" get message from {} failed", 200 | project_name, msg_dec.content 201 | ) 202 | .as_str(), 203 | ); 204 | }; 205 | 206 | let (cmd_ptr, mut cmd_match_res) = if msg_ntf.event_type.is_empty() { 207 | let default_cmd_name = if msg_ntf.content.trim().is_empty() { 208 | "" 209 | } else { 210 | "default" 211 | }; 212 | // 查找匹配命令 213 | let (cp, mut cmr, is_default_cmd) = 214 | if let Some((x, y)) = proj_obj.try_commands(&msg_ntf.content, false) { 215 | // project 域内查找命令 216 | (x, y, false) 217 | } else if let Some((x, y)) = app.get_global_command(&msg_ntf.content, false) { 218 | // global 域内查找命令 219 | (x, y, false) 220 | } else if let Some((x, y)) = proj_obj.try_commands(default_cmd_name, true) { 221 | // project 域内查找默认命令 222 | (x, y, true) 223 | } else if let Some((x, y)) = app.get_global_command(default_cmd_name, true) { 224 | // global 域内查找默认命令 225 | (x, y, true) 226 | } else if default_cmd_name.is_empty() { 227 | return message::make_robot_empty_response(); 228 | } else { 229 | return message::make_robot_not_found_response(format!( 230 | "project \"{}\" get command from {} failed", 231 | project_name, msg_ntf.content 232 | )); 233 | }; 234 | 235 | if is_default_cmd { 236 | cmr.mut_json()["WXWORK_ROBOT_CMD"] = serde_json::Value::String(msg_ntf.content.clone()); 237 | } 238 | 239 | (cp, cmr) 240 | } else { 241 | // 查找匹配事件 242 | let (cp, cmr, _) = if let Some((x, y)) = proj_obj.try_events(&msg_ntf.event_type, true) { 243 | // project 域内查找事件 244 | (x, y, false) 245 | } else if let Some((x, y)) = app.get_global_event(&msg_ntf.event_type, true) { 246 | // global 域内查找事件 247 | (x, y, false) 248 | } else { 249 | return message::make_robot_empty_response(); 250 | }; 251 | 252 | (cp, cmr) 253 | }; 254 | cmd_match_res.mut_json()["WXWORK_ROBOT_VERSION"] = 255 | serde_json::Value::String(crate_version!().to_string()); 256 | cmd_match_res.mut_json()["WXWORK_ROBOT_WEBHOOK_KEY"] = 257 | serde_json::Value::String(msg_ntf.web_hook_key.clone()); 258 | cmd_match_res.mut_json()["WXWORK_ROBOT_WEBHOOK_URL"] = 259 | serde_json::Value::String(msg_ntf.web_hook_url.clone()); 260 | cmd_match_res.mut_json()["WXWORK_ROBOT_MSG_FROM_USER_ID"] = 261 | serde_json::Value::String(msg_ntf.from.user_id.clone()); 262 | cmd_match_res.mut_json()["WXWORK_ROBOT_MSG_FROM_NAME"] = 263 | serde_json::Value::String(msg_ntf.from.name.clone()); 264 | cmd_match_res.mut_json()["WXWORK_ROBOT_MSG_FROM_ALIAS"] = 265 | serde_json::Value::String(msg_ntf.from.alias.clone()); 266 | cmd_match_res.mut_json()["WXWORK_ROBOT_MSG_ID"] = 267 | serde_json::Value::String(msg_ntf.msg_id.clone()); 268 | cmd_match_res.mut_json()["WXWORK_ROBOT_IMAGE_URL"] = 269 | serde_json::Value::String(msg_ntf.image_url.clone()); 270 | cmd_match_res.mut_json()["WXWORK_ROBOT_GET_CHAT_INFO_URL"] = 271 | serde_json::Value::String(msg_ntf.get_chat_info_url.clone()); 272 | cmd_match_res.mut_json()["WXWORK_ROBOT_POST_ID"] = 273 | serde_json::Value::String(msg_ntf.post_id.clone()); 274 | cmd_match_res.mut_json()["WXWORK_ROBOT_CHAT_ID"] = 275 | serde_json::Value::String(msg_ntf.chat_id.clone()); 276 | cmd_match_res.mut_json()["WXWORK_ROBOT_CHAT_TYPE"] = 277 | serde_json::Value::String(msg_ntf.chat_type.clone()); 278 | cmd_match_res.mut_json()["WXWORK_ROBOT_MSG_TYPE"] = 279 | serde_json::Value::String(msg_ntf.msg_type.clone()); 280 | cmd_match_res.mut_json()["WXWORK_ROBOT_APP_VERSION"] = 281 | serde_json::Value::String(msg_ntf.app_version.clone()); 282 | cmd_match_res.mut_json()["WXWORK_ROBOT_EVENT_TYPE"] = 283 | serde_json::Value::String(msg_ntf.event_type.clone()); 284 | cmd_match_res.mut_json()["WXWORK_ROBOT_ACTION_NAME"] = 285 | serde_json::Value::String(msg_ntf.action_name.clone()); 286 | cmd_match_res.mut_json()["WXWORK_ROBOT_ACTION_VALUE"] = 287 | serde_json::Value::String(msg_ntf.action_value.clone()); 288 | cmd_match_res.mut_json()["WXWORK_ROBOT_ACTION_CALLBACKID"] = 289 | serde_json::Value::String(msg_ntf.action_value.clone()); 290 | 291 | // 填充模板参数json 292 | let template_vars = proj_obj.generate_template_vars(&cmd_match_res); 293 | let runtime = Arc::new(command_runtime::WxWorkCommandRuntime { 294 | proj: proj_obj.clone(), 295 | cmd: cmd_ptr, 296 | cmd_match: cmd_match_res, 297 | envs: template_vars, 298 | msg: msg_ntf, 299 | }); 300 | 301 | command_runtime::run(runtime).await 302 | } 303 | 304 | #[cfg(test)] 305 | mod tests { 306 | use super::super::super::wxwork_robot::base64; 307 | use super::super::super::wxwork_robot::message; 308 | use super::super::super::wxwork_robot::project::WxWorkProject; 309 | use actix_web::web; 310 | 311 | const WXWORKROBOT_TEST_MSG_ORIGIN: &[u8] = b""; 312 | const WXWORKROBOT_TEST_MSG_REPLY: &str = ""; 313 | 314 | #[test] 315 | fn project_decode_and_verify() { 316 | let encrypt_msg_b64_res = 317 | message::get_msg_encrypt_from_bytes(web::Bytes::from(WXWORKROBOT_TEST_MSG_ORIGIN)); 318 | assert!(encrypt_msg_b64_res.is_some()); 319 | 320 | let json_value = serde_json::from_str("{ \"name\": \"test_proj\", \"token\": \"hJqcu3uJ9Tn2gXPmxx2w9kkCkCE2EPYo\", \"encodingAESKey\": \"6qkdMrq68nTKduznJYO1A37W2oEgpkMUvkttRToqhUt\", \"cmds\": {} }").unwrap(); 321 | let proj_obj_res = WxWorkProject::new(&json_value); 322 | assert!(proj_obj_res.is_some()); 323 | if !proj_obj_res.is_some() { 324 | return; 325 | } 326 | let proj_obj = proj_obj_res.unwrap(); 327 | 328 | let msg_dec: message::WxWorkMessageDec; 329 | if let Some(encrypt_msg_b64) = encrypt_msg_b64_res { 330 | assert!(proj_obj.check_msg_signature( 331 | "8fa1a2c27ee20431b1f781600d0af971db3cc12b", 332 | "1592905675", 333 | "da455e270d961d94", 334 | encrypt_msg_b64.as_str() 335 | )); 336 | 337 | let msg_dec_res = proj_obj.decrypt_msg_raw_base64_content(encrypt_msg_b64.as_str()); 338 | assert!(msg_dec_res.is_ok()); 339 | msg_dec = if let Ok(x) = msg_dec_res { 340 | x 341 | } else { 342 | return; 343 | }; 344 | } else { 345 | return; 346 | } 347 | 348 | // 提取数据 349 | let msg_ntf_res = message::get_msg_from_str(msg_dec.content.as_str()); 350 | assert!(msg_ntf_res.is_some()); 351 | let msg_ntf = if let Some(x) = msg_ntf_res { 352 | x 353 | } else { 354 | return; 355 | }; 356 | 357 | assert_eq!(msg_ntf.content, "@测试机器人 说啦啦啦热热热"); 358 | } 359 | 360 | #[test] 361 | fn project_encode_reply() { 362 | let json_value = serde_json::from_str("{ \"name\": \"test_proj\", \"token\": \"hJqcu3uJ9Tn2gXPmxx2w9kkCkCE2EPYo\", \"encodingAESKey\": \"6qkdMrq68nTKduznJYO1A37W2oEgpkMUvkttRToqhUt\", \"cmds\": {} }").unwrap(); 363 | let proj_obj_res = WxWorkProject::new(&json_value); 364 | assert!(proj_obj_res.is_some()); 365 | if !proj_obj_res.is_some() { 366 | return; 367 | } 368 | let proj_obj = proj_obj_res.unwrap(); 369 | 370 | let random_str = String::from("5377875643139089"); 371 | let encrypted_res = 372 | proj_obj.encrypt_msg_raw(&WXWORKROBOT_TEST_MSG_REPLY.as_bytes(), &random_str); 373 | assert!(encrypted_res.is_ok()); 374 | 375 | let encrypted_base64 = if let Ok(x) = encrypted_res { 376 | match base64::STANDARD.encode(&x) { 377 | Ok(v) => v, 378 | Err(_) => { 379 | assert!(false); 380 | return; 381 | } 382 | } 383 | } else { 384 | return; 385 | }; 386 | 387 | assert_eq!(encrypted_base64, "i84WNcyej8+Vo0tCZHLxCWt3ObZ2mvzs0cIGXLleX43mjd+TK1SYqdUOuPMS32ZJK0QyAq+Y6eVwqObEjrLTxGnlEeMOH2/f1CMxcPiRXUOTzOP4/qyeYI+PF9wAuJIajfJMHZCUiUSjS5cs18AS3XnO3VoP1hnGkMkxNy3CBFqQzgVkGsHhz3cQK94tzlkPWsveB8qQZjOJWxHst2Y+8Q=="); 388 | } 389 | } 390 | -------------------------------------------------------------------------------- /tools/qyapi_wx_send_robot.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """ 4 | 使用企业微信提供的Robot服务在企业微信群发送消息 5 | """ 6 | 7 | import os 8 | import sys 9 | import json 10 | import codecs 11 | 12 | FILE_ENCODING = 'utf-8' 13 | 14 | NEWS_DEFAULT_TITLE = "NO TITLE" 15 | NEWS_DEFAULT_URL = "https://owent.net" 16 | 17 | if sys.version_info[0] == 2: 18 | 19 | def CmdArgsGetParser(usage): 20 | reload(sys) 21 | sys.setdefaultencoding('utf-8') 22 | from optparse import OptionParser 23 | return OptionParser('usage: %(prog)s ' + usage) 24 | 25 | def CmdArgsAddOption(parser, *args, **kwargs): 26 | parser.add_option(*args, **kwargs) 27 | 28 | def CmdArgsParse(parser): 29 | return parser.parse_args()[0] 30 | 31 | def SendHttpRequest(url_prefix, 32 | post_body=None, 33 | headers={}, 34 | url_suffix=None): 35 | import requests 36 | import urllib2 37 | if url_suffix is None: 38 | url = url_prefix 39 | else: 40 | url = urllib2.urlparse.urljoin(url_prefix, url_suffix) 41 | if post_body is None: 42 | rsp = requests.get(url, headers=headers, timeout=30.0) 43 | else: 44 | rsp = requests.post(url, 45 | headers=headers, 46 | data=post_body, 47 | timeout=30.0) 48 | if rsp.status_code != 200: 49 | rsp.raise_for_status() 50 | else: 51 | return rsp.content.decode('utf-8') 52 | 53 | else: 54 | 55 | def CmdArgsGetParser(usage): 56 | from argparse import ArgumentParser 57 | return ArgumentParser(usage="%(prog)s " + usage) 58 | 59 | def CmdArgsAddOption(parser, *args, **kwargs): 60 | parser.add_argument(*args, **kwargs) 61 | 62 | def CmdArgsParse(parser): 63 | return parser.parse_args() 64 | 65 | def SendHttpRequest(url_prefix, 66 | post_body=None, 67 | headers={}, 68 | url_suffix=None): 69 | import urllib.request 70 | import urllib.parse 71 | if url_suffix is None: 72 | url = url_prefix 73 | else: 74 | url = urllib.parse.urljoin(url_prefix, url_suffix) 75 | 76 | if post_body is None: 77 | req = urllib.request.Request(url=url, method='GET') 78 | else: 79 | req = urllib.request.Request(url=url, method='POST') 80 | 81 | for k in headers: 82 | req.add_header(k, headers[k]) 83 | 84 | if post_body is None: 85 | with urllib.request.urlopen(req, 30.0) as f: 86 | return f.read().decode('utf-8') 87 | else: 88 | req.add_header('Content-Length', len(post_body)) 89 | with urllib.request.urlopen(req, post_body, 30.0) as f: 90 | return f.read().decode('utf-8') 91 | 92 | 93 | def ReadMessageContent(file): 94 | if 'stdin' == file: 95 | return sys.stdin.read() 96 | if os.path.exists(file): 97 | fd = codecs.open(file, 98 | mode='r', 99 | encoding=FILE_ENCODING, 100 | errors="ignore") 101 | return fd.read() 102 | return file 103 | 104 | 105 | def SendWXRobotRawMessage(url, 106 | msg, 107 | chat_id=None, 108 | visible_to_user=[], 109 | show_verbose=False): 110 | try: 111 | if chat_id is not None and chat_id: 112 | msg["chatid"] = chat_id 113 | 114 | if visible_to_user is not None and visible_to_user: 115 | msg["visible_to_user"] = '|'.join(visible_to_user) 116 | 117 | json_content = json.dumps(msg, indent=2) 118 | if show_verbose: 119 | print('Request with json: {0}'.format(json_content)) 120 | response_content = SendHttpRequest( 121 | url, 122 | post_body=json_content.encode('utf-8'), 123 | headers={ 124 | "Content-Type": "application/json; charset=utf-8" #, 125 | #"Expect": "100-continue" 126 | }) 127 | if show_verbose: 128 | print('Got response: {0}'.format(response_content)) 129 | json_decoder = json.JSONDecoder(strict=False) 130 | return json_decoder.decode(response_content) 131 | except Exception as e: 132 | return '{0}'.format(e) 133 | 134 | 135 | def SendWXRobotText(url, 136 | text, 137 | mentioned_list=[], 138 | mentioned_mobile_list=[], 139 | chat_id=None, 140 | visible_to_user=[], 141 | show_verbose=False): 142 | msg = { 143 | "msgtype": "text", 144 | "text": { 145 | "content": text, 146 | "mentioned_list": mentioned_list, 147 | "mentioned_mobile_list": mentioned_mobile_list 148 | } 149 | } 150 | return SendWXRobotRawMessage(url, 151 | msg, 152 | chat_id, 153 | visible_to_user, 154 | show_verbose=show_verbose) 155 | 156 | 157 | def SendWXRobotMarkdown(url, 158 | markdown, 159 | chat_id=None, 160 | visible_to_user=[], 161 | show_verbose=False): 162 | msg = {"msgtype": "markdown", "markdown": {"content": markdown}} 163 | return SendWXRobotRawMessage(url, 164 | msg, 165 | chat_id, 166 | visible_to_user, 167 | show_verbose=show_verbose) 168 | 169 | 170 | def SendWXRobotImage(url, 171 | image_binary, 172 | chat_id=None, 173 | visible_to_user=[], 174 | show_verbose=False): 175 | import base64 176 | import hashlib 177 | msg = { 178 | "msgtype": "image", 179 | "image": { 180 | "base64": base64.standard_b64encode(image_binary).decode('utf-8'), 181 | "md5": hashlib.md5(image_binary).hexdigest() 182 | } 183 | } 184 | return SendWXRobotRawMessage(url, 185 | msg, 186 | chat_id, 187 | visible_to_user, 188 | show_verbose=show_verbose) 189 | 190 | 191 | def SendWXRobotNews(url, 192 | news_list_array, 193 | chat_id=None, 194 | visible_to_user=[], 195 | show_verbose=False): 196 | news_data = [] 197 | if news_list_array is None or not news_list_array: 198 | news_data.append({ 199 | "title": NEWS_DEFAULT_TITLE, 200 | "url": NEWS_DEFAULT_URL 201 | }) 202 | else: 203 | for news_post in news_list_array: 204 | post_json = {"title": NEWS_DEFAULT_TITLE, "url": NEWS_DEFAULT_URL} 205 | if "title" in news_post: 206 | post_json["title"] = news_post["title"] 207 | if "description" in news_post: 208 | post_json["description"] = news_post["description"] 209 | if "url" in news_post: 210 | post_json["url"] = news_post["url"] 211 | if "picurl" in news_post: 212 | post_json["picurl"] = news_post["picurl"] 213 | news_data.append(news_post) 214 | 215 | msg = {"msgtype": "news", "news": {"articles": news_data}} 216 | return SendWXRobotRawMessage(url, 217 | msg, 218 | chat_id, 219 | visible_to_user, 220 | show_verbose=show_verbose) 221 | 222 | 223 | def SendWXRobotTemplateMessage(url, 224 | template_id, 225 | user_data=None, 226 | chat_id=None, 227 | visible_to_user=[], 228 | show_verbose=False): 229 | if user_data is None: 230 | user_data = {} 231 | msg = { 232 | "msgtype": "template", 233 | "template_id": template_id, 234 | "user_data": json.dumps(user_data) 235 | } 236 | return SendWXRobotRawMessage(url, 237 | msg, 238 | chat_id, 239 | visible_to_user, 240 | show_verbose=show_verbose) 241 | 242 | 243 | if __name__ == '__main__': 244 | parser = CmdArgsGetParser('[options]...') 245 | CmdArgsAddOption( 246 | parser, 247 | "-r", 248 | "--robot-url", 249 | action="store", 250 | help= 251 | "set robot url(for example: https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=633a31f6-7f9c-4bc4-97a0-0ec1eefa589)", 252 | dest="robot_url", 253 | default=None) 254 | CmdArgsAddOption( 255 | parser, 256 | "-t", 257 | "--text", 258 | action="store", 259 | help= 260 | "set message text file path(文本文件内容,最长不超过2048个字节, 输入 stdin 则是从标准输入读取)", 261 | dest="text", 262 | default=None) 263 | CmdArgsAddOption( 264 | parser, 265 | "-m", 266 | "--markdown", 267 | action="store", 268 | help= 269 | "set message markdown file path(Markdown文件内容,最长不超过2048个字节, 输入 stdin 则是从标准输入读取)", 270 | dest="markdown", 271 | default=None) 272 | CmdArgsAddOption(parser, 273 | "-i", 274 | "--image", 275 | action="store", 276 | help="set image file path(图片文件地址,最大不能超过2M,支持JPG,PNG格式)", 277 | dest="image", 278 | default=None) 279 | CmdArgsAddOption(parser, 280 | "--news-title", 281 | action="append", 282 | help="add news title", 283 | dest="news_title", 284 | default=[]) 285 | CmdArgsAddOption(parser, 286 | "--news-description", 287 | action="append", 288 | help="add news description", 289 | dest="news_description", 290 | default=[]) 291 | CmdArgsAddOption(parser, 292 | "--news-url", 293 | action="append", 294 | help="add news url", 295 | dest="news_url", 296 | default=[]) 297 | CmdArgsAddOption(parser, 298 | "--news-picurl", 299 | action="append", 300 | help="add news picurl", 301 | dest="news_picurl", 302 | default=[]) 303 | CmdArgsAddOption(parser, 304 | "--raw", 305 | action="store", 306 | help="set raw json content of robot message", 307 | dest="raw", 308 | default=None) 309 | CmdArgsAddOption(parser, 310 | "--template-id", 311 | action="store", 312 | help="set template if of template message", 313 | dest="template_id", 314 | default=None) 315 | CmdArgsAddOption( 316 | parser, 317 | "--template-user-data", 318 | action="store", 319 | help= 320 | "set user data(json) of template message(Use ${user_data.varname} in template)", 321 | dest="template_user_data", 322 | default=None) 323 | CmdArgsAddOption(parser, 324 | "-f", 325 | "--file-encoding", 326 | action="store", 327 | help="set encoding of text file or markdown file", 328 | dest="file_encoding", 329 | default='utf-8') 330 | CmdArgsAddOption( 331 | parser, 332 | "-e", 333 | "--mentioned-list", 334 | action="append", 335 | help= 336 | "set mentioned list(userid的列表,提醒群中的指定成员(@某个成员),@all表示提醒所有人,如果开发者获取不到userid,可以使用mentioned_mobile_list)", 337 | dest="mentioned_list", 338 | default=[]) 339 | CmdArgsAddOption( 340 | parser, 341 | "-n", 342 | "--mentioned-mobile-list", 343 | action="append", 344 | help="set mentioned mobile list(手机号列表,提醒手机号对应的群成员(@某个成员),@all表示提醒所有人)", 345 | dest="mentioned_mobile_list", 346 | default=[]) 347 | CmdArgsAddOption(parser, 348 | "-c", 349 | "--chat-id", 350 | action="store", 351 | help="set chat id", 352 | dest="chat_id", 353 | default=None) 354 | CmdArgsAddOption(parser, 355 | "--visible-to-user", 356 | action="append", 357 | help="set visible user list(userid的列表,仅部分人可见)", 358 | dest="visible_to_user", 359 | default=[]) 360 | CmdArgsAddOption(parser, 361 | "-V", 362 | "--verbose", 363 | action="store_true", 364 | help="show verbose log", 365 | dest="show_verbose", 366 | default=False) 367 | opts = CmdArgsParse(parser) 368 | if opts.robot_url is None: 369 | print('robot-url is required\n use options -h for more details.') 370 | exit(1) 371 | 372 | has_message = False 373 | FILE_ENCODING = opts.file_encoding 374 | if opts.text is not None: 375 | has_message = True 376 | print( 377 | SendWXRobotText(opts.robot_url, 378 | ReadMessageContent(opts.text), 379 | opts.mentioned_list, 380 | opts.mentioned_mobile_list, 381 | chat_id=opts.chat_id, 382 | visible_to_user=opts.visible_to_user, 383 | show_verbose=opts.show_verbose)) 384 | if opts.markdown is not None: 385 | has_message = True 386 | print( 387 | SendWXRobotMarkdown(opts.robot_url, 388 | ReadMessageContent(opts.markdown), 389 | chat_id=opts.chat_id, 390 | visible_to_user=opts.visible_to_user, 391 | show_verbose=opts.show_verbose)) 392 | if opts.image is not None: 393 | has_message = True 394 | if not os.path.exists(opts.image): 395 | sys.stderr.writelines( 396 | ['Image file \"{0}\" not found'.format(opts.image)]) 397 | else: 398 | print( 399 | SendWXRobotImage(opts.robot_url, 400 | open(opts.image, 'rb').read(), 401 | chat_id=opts.chat_id, 402 | visible_to_user=opts.visible_to_user, 403 | show_verbose=opts.show_verbose)) 404 | 405 | news_count = max(len(opts.news_title), len(opts.news_description), 406 | len(opts.news_url), len(opts.news_picurl)) 407 | news_list = [] 408 | for i in range(0, news_count): 409 | post = {"title": NEWS_DEFAULT_TITLE, "url": NEWS_DEFAULT_URL} 410 | if i < len(opts.news_title): 411 | post["title"] = opts.news_title[i] 412 | if i < len(opts.news_description): 413 | post["description"] = opts.news_description[i] 414 | if i < len(opts.news_url): 415 | post["url"] = opts.news_url[i] 416 | if i < len(opts.news_picurl): 417 | post["picurl"] = opts.news_picurl[i] 418 | news_list.append(post) 419 | 420 | if news_list: 421 | has_message = True 422 | print( 423 | SendWXRobotNews(opts.robot_url, 424 | news_list, 425 | chat_id=opts.chat_id, 426 | visible_to_user=opts.visible_to_user, 427 | show_verbose=opts.show_verbose)) 428 | 429 | if opts.template_id is not None: 430 | parse_json = False 431 | try: 432 | if opts.template_user_data is not None: 433 | json_decoder = json.JSONDecoder(strict=False) 434 | template_user_data = json_decoder.decode( 435 | opts.template_user_data) 436 | else: 437 | template_user_data = {} 438 | parse_json = True 439 | except Exception as e: 440 | sys.stderr.writelines([ 441 | 'Parse json user data failed, {0}\n{1}'.format( 442 | e, opts.template_user_data) 443 | ]) 444 | if parse_json: 445 | has_message = True 446 | print( 447 | SendWXRobotTemplateMessage( 448 | opts.robot_url, 449 | opts.template_id, 450 | template_user_data, 451 | chat_id=opts.chat_id, 452 | visible_to_user=opts.visible_to_user, 453 | show_verbose=opts.show_verbose)) 454 | if opts.raw is not None: 455 | parse_json = None 456 | try: 457 | json_decoder = json.JSONDecoder(strict=False) 458 | parse_json = json_decoder.decode(opts.raw) 459 | except Exception as e: 460 | sys.stderr.writelines([ 461 | 'Parse raw json message failed, {0}\n{1}'.format(e, opts.raw) 462 | ]) 463 | if parse_json is not None: 464 | has_message = True 465 | print( 466 | SendWXRobotRawMessage(opts.robot_url, 467 | parse_json, 468 | chat_id=opts.chat_id, 469 | visible_to_user=opts.visible_to_user, 470 | show_verbose=opts.show_verbose)) 471 | 472 | if not has_message: 473 | print('no message send.') 474 | exit(1) 475 | -------------------------------------------------------------------------------- /src/wxwork_robot/command_runtime.rs: -------------------------------------------------------------------------------- 1 | use actix_web::HttpResponse; 2 | 3 | use std::fs::OpenOptions; 4 | use std::io::{BufReader, Read}; 5 | use std::process::Stdio; 6 | use std::sync::Arc; 7 | use std::time::Duration; 8 | 9 | use regex::{Regex, RegexBuilder}; 10 | 11 | use tokio::process::Command; 12 | use tokio::time::timeout; 13 | 14 | use actix_web::http; 15 | use awc; 16 | 17 | use handlebars::Handlebars; 18 | 19 | use super::super::app; 20 | use super::{command, message, project}; 21 | 22 | // #[derive(Clone)] 23 | pub struct WxWorkCommandRuntime { 24 | pub proj: project::WxWorkProjectPtr, 25 | pub cmd: command::WxWorkCommandPtr, 26 | pub cmd_match: command::WxWorkCommandMatch, 27 | pub envs: serde_json::Value, 28 | pub msg: message::WxWorkMessageNtf, 29 | } 30 | 31 | lazy_static! { 32 | static ref PICK_AT_RULE: Regex = RegexBuilder::new(r"@(?P\S+)") 33 | .case_insensitive(false) 34 | .build() 35 | .unwrap(); 36 | } 37 | 38 | pub fn get_project_name_from_runtime(runtime: &Arc) -> Arc { 39 | runtime.proj.name() 40 | } 41 | 42 | pub fn get_command_name_from_runtime(runtime: &Arc) -> Arc { 43 | runtime.cmd.name() 44 | } 45 | 46 | // #[allow(unused)] 47 | pub async fn run(runtime: Arc) -> HttpResponse { 48 | debug!( 49 | "dispatch for command \"{}\"({})", 50 | runtime.cmd.name(), 51 | runtime.cmd.description() 52 | ); 53 | 54 | match runtime.cmd.data { 55 | command::WxWorkCommandData::Echo(_) => run_echo(runtime).await, 56 | command::WxWorkCommandData::Http(_) => run_http(runtime).await, 57 | command::WxWorkCommandData::Help(_) => run_help(runtime).await, 58 | command::WxWorkCommandData::Spawn(_) => run_spawn(runtime).await, 59 | command::WxWorkCommandData::Ignore => run_ignore(runtime).await, 60 | // _ => run_test, 61 | } 62 | } 63 | 64 | #[allow(unused)] 65 | async fn run_test(_: Arc) -> HttpResponse { 66 | HttpResponse::Ok() 67 | .content_type("text/html; charset=utf-8") 68 | .body("test success") 69 | } 70 | 71 | async fn run_ignore(_: Arc) -> HttpResponse { 72 | message::make_robot_empty_response() 73 | } 74 | 75 | async fn run_help(runtime: Arc) -> HttpResponse { 76 | let (echo_prefix, echo_suffix) = 77 | if let command::WxWorkCommandData::Help(ref x) = runtime.cmd.data { 78 | (x.prefix.clone(), x.suffix.clone()) 79 | } else { 80 | (String::default(), String::default()) 81 | }; 82 | 83 | let mut output = String::with_capacity(4096); 84 | let reg = Handlebars::new(); 85 | if !echo_prefix.is_empty() { 86 | output += (match reg.render_template(echo_prefix.as_str(), &runtime.envs) { 87 | Ok(x) => x, 88 | Err(e) => format!("{:?}", e), 89 | }) 90 | .as_str(); 91 | output += "\r\n"; 92 | } 93 | 94 | let mut cmd_index = 1; 95 | for cmd in runtime.proj.cmds.as_ref() { 96 | if let Some(desc) = command::get_command_description(cmd) { 97 | output += format!("> {}. {}\r\n", cmd_index, desc).as_str(); 98 | cmd_index += 1; 99 | } 100 | } 101 | 102 | let app_env = app::app(); 103 | for cmd in app_env.get_global_command_list().as_ref() { 104 | if let Some(desc) = command::get_command_description(cmd) { 105 | output += format!("> {}. {}\r\n", cmd_index, desc).as_str(); 106 | cmd_index += 1; 107 | } 108 | } 109 | 110 | if !echo_suffix.is_empty() { 111 | output += (match reg.render_template(echo_suffix.as_str(), &runtime.envs) { 112 | Ok(x) => x, 113 | Err(e) => format!("{:?}", e), 114 | }) 115 | .as_str(); 116 | } 117 | 118 | debug!("Help message: \n{}", output); 119 | 120 | runtime.proj.make_markdown_response_with_text(output) 121 | } 122 | 123 | async fn run_echo(runtime: Arc) -> HttpResponse { 124 | let echo_input = if let command::WxWorkCommandData::Echo(ref x) = runtime.cmd.data { 125 | x.echo.clone() 126 | } else { 127 | String::from("Hello world!") 128 | }; 129 | 130 | let reg = Handlebars::new(); 131 | let echo_output = match reg.render_template(echo_input.as_str(), &runtime.envs) { 132 | Ok(x) => x, 133 | Err(e) => format!("{:?}", e), 134 | }; 135 | 136 | debug!("Echo message: \n{}", echo_output); 137 | runtime.proj.make_markdown_response_with_text(echo_output) 138 | } 139 | 140 | async fn run_http(runtime: Arc) -> HttpResponse { 141 | let http_req_f; 142 | let http_url; 143 | let reg; 144 | let echo_output_tmpl_str; 145 | 146 | { 147 | let http_data = if let command::WxWorkCommandData::Http(ref x) = runtime.cmd.data { 148 | x.clone() 149 | } else { 150 | return runtime 151 | .proj 152 | .make_error_response(String::from("Configure type error")); 153 | }; 154 | 155 | if http_data.url.is_empty() { 156 | let err_msg = "Missing Request URL".to_string(); 157 | error!( 158 | "project \"{}\" command \"{}\" {}", 159 | runtime.proj.name(), 160 | runtime.cmd.name(), 161 | err_msg 162 | ); 163 | return runtime.proj.make_error_response(err_msg); 164 | } 165 | 166 | reg = Handlebars::new(); 167 | http_url = match reg.render_template(http_data.url.as_str(), &runtime.envs) { 168 | Ok(x) => x, 169 | Err(e) => format!("{:?}", e), 170 | }; 171 | 172 | echo_output_tmpl_str = if http_data.echo.is_empty() { 173 | String::from("Ok") 174 | } else { 175 | http_data.echo.clone() 176 | }; 177 | let post_data = match reg.render_template(http_data.post.as_str(), &runtime.envs) { 178 | Ok(x) => x, 179 | Err(_) => String::default(), 180 | }; 181 | 182 | { 183 | let mut http_request = match http_data.method { 184 | command::WxWorkCommandHttpMethod::Auto => { 185 | if !post_data.is_empty() { 186 | awc::Client::default().post(http_url.as_str()) 187 | } else { 188 | awc::Client::default().get(http_url.as_str()) 189 | } 190 | } 191 | command::WxWorkCommandHttpMethod::Get => { 192 | awc::Client::default().get(http_url.as_str()) 193 | } 194 | command::WxWorkCommandHttpMethod::Post => { 195 | awc::Client::default().post(http_url.as_str()) 196 | } 197 | command::WxWorkCommandHttpMethod::Delete => { 198 | awc::Client::default().delete(http_url.as_str()) 199 | } 200 | command::WxWorkCommandHttpMethod::Put => { 201 | awc::Client::default().put(http_url.as_str()) 202 | } 203 | command::WxWorkCommandHttpMethod::Head => { 204 | awc::Client::default().head(http_url.as_str()) 205 | } 206 | }; 207 | http_request = http_request 208 | .timeout(Duration::from_millis(app::app_conf().task_timeout)) 209 | .insert_header_if_none(( 210 | http::header::USER_AGENT, 211 | format!("Mozilla/5.0 (WXWork-Robotd {})", crate_version!()), 212 | )); 213 | if !http_data.content_type.is_empty() { 214 | http_request = http_request.insert_header_if_none(( 215 | http::header::CONTENT_TYPE, 216 | http_data.content_type.as_str(), 217 | )); 218 | } 219 | for (k, v) in &http_data.headers { 220 | http_request = http_request.insert_header_if_none((k.as_str(), v.as_str())); 221 | } 222 | 223 | http_req_f = http_request.send_body(post_data); 224 | } 225 | 226 | // if let Err(e) = http_req_f { 227 | // let err_msg = format!("Make request to {} failed, {:?}", http_url, e); 228 | // error!( 229 | // "project \"{}\" command \"{}\" {}", 230 | // runtime.proj.name(), 231 | // runtime.cmd.name(), 232 | // err_msg 233 | // ); 234 | // return Box::new(future_ok(runtime.proj.make_error_response(err_msg))); 235 | // } 236 | } 237 | 238 | let mut http_rsp = match http_req_f.await { 239 | Ok(x) => x, 240 | Err(e) => { 241 | let err_msg = format!("Make request to {} failed, {:?}", http_url, e); 242 | error!( 243 | "project \"{}\" command \"{}\" {}", 244 | runtime.proj.name(), 245 | runtime.cmd.name(), 246 | err_msg 247 | ); 248 | return runtime.proj.make_error_response(err_msg); 249 | } 250 | }; 251 | 252 | let rsp_data = match http_rsp.body().await { 253 | Ok(x) => x, 254 | Err(e) => { 255 | let err_msg = format!("{:?}", e); 256 | error!( 257 | "project \"{}\" command \"{}\" get response from {} failed: {:?}", 258 | get_project_name_from_runtime(&runtime), 259 | get_command_name_from_runtime(&runtime), 260 | http_url, 261 | err_msg 262 | ); 263 | return runtime.proj.make_markdown_response_with_text(err_msg); 264 | } 265 | }; 266 | 267 | let data_str = if let Ok(x) = String::from_utf8(rsp_data.to_vec()) { 268 | x 269 | } else { 270 | hex::encode(&rsp_data) 271 | }; 272 | 273 | info!( 274 | "project \"{}\" command \"{}\" get response from {}: \n{:?}", 275 | get_project_name_from_runtime(&runtime), 276 | get_command_name_from_runtime(&runtime), 277 | http_url, 278 | data_str 279 | ); 280 | 281 | let mut vars_for_rsp = runtime.envs.clone(); 282 | if vars_for_rsp.is_object() { 283 | vars_for_rsp["WXWORK_ROBOT_HTTP_RESPONSE"] = serde_json::Value::String(data_str); 284 | } 285 | let echo_output = match reg.render_template(echo_output_tmpl_str.as_str(), &vars_for_rsp) { 286 | Ok(x) => x, 287 | Err(e) => format!("{:?}", e), 288 | }; 289 | runtime.proj.make_markdown_response_with_text(echo_output) 290 | } 291 | 292 | async fn run_spawn(runtime: Arc) -> HttpResponse { 293 | let spawn_data = if let command::WxWorkCommandData::Spawn(ref x) = runtime.cmd.data { 294 | x.clone() 295 | } else { 296 | return runtime 297 | .proj 298 | .make_error_response(String::from("Configure type error")); 299 | }; 300 | 301 | let reg = Handlebars::new(); 302 | let exec = match reg.render_template(spawn_data.exec.as_str(), &runtime.envs) { 303 | Ok(x) => x, 304 | Err(_) => spawn_data.exec.clone(), 305 | }; 306 | let cwd = match reg.render_template(spawn_data.cwd.as_str(), &runtime.envs) { 307 | Ok(x) => x, 308 | Err(_) => spawn_data.cwd.clone(), 309 | }; 310 | 311 | let mut args = Vec::with_capacity(spawn_data.args.capacity()); 312 | for v in &spawn_data.args { 313 | args.push(match reg.render_template(v.as_str(), &runtime.envs) { 314 | Ok(x) => x, 315 | Err(_) => v.clone(), 316 | }); 317 | } 318 | 319 | let output_type = spawn_data.output_type; 320 | 321 | info!("Spawn message: (CWD={}) {} {}", cwd, exec, &args.join(" ")); 322 | let mut child = Command::new(exec.as_str()); 323 | child.stdin(Stdio::null()); 324 | child.stdout(Stdio::piped()); 325 | child.stderr(Stdio::piped()); 326 | child.kill_on_drop(true); 327 | 328 | for ref v in args { 329 | child.arg(v.as_str()); 330 | } 331 | 332 | if let Some(kvs) = runtime.envs.as_object() { 333 | for (k, v) in kvs { 334 | match v { 335 | serde_json::Value::Null => {} 336 | serde_json::Value::Bool(x) => { 337 | child.env(k.as_str(), if *x { "1" } else { "0" }); 338 | } 339 | serde_json::Value::Number(x) => { 340 | child.env(k.as_str(), x.to_string()); 341 | } 342 | serde_json::Value::String(x) => { 343 | child.env(k.as_str(), x.as_str()); 344 | } 345 | x => { 346 | child.env(k.as_str(), x.to_string()); 347 | } 348 | } 349 | } 350 | } 351 | 352 | if !cwd.is_empty() { 353 | child.current_dir(cwd); 354 | } 355 | 356 | let async_job = match child.spawn() { 357 | Ok(x) => x, 358 | Err(e) => { 359 | let err_msg = format!("Run command failed, {:?}", e); 360 | error!( 361 | "project \"{}\" command \"{}\" {}", 362 | runtime.proj.name(), 363 | runtime.cmd.name(), 364 | err_msg 365 | ); 366 | return runtime.proj.make_error_response(err_msg); 367 | } 368 | }; 369 | 370 | let run_result = match timeout( 371 | Duration::from_millis(app::app_conf().task_timeout), 372 | async_job.wait_with_output(), 373 | ) 374 | .await 375 | { 376 | Ok(x) => x, 377 | Err(e) => { 378 | let err_msg = format!("Run command timeout, {:?}", e); 379 | error!( 380 | "project \"{}\" command \"{}\" {}", 381 | runtime.proj.name(), 382 | runtime.cmd.name(), 383 | err_msg 384 | ); 385 | return runtime.proj.make_markdown_response_with_text(err_msg); 386 | } 387 | }; 388 | 389 | let output = match run_result { 390 | Ok(x) => x, 391 | Err(e) => { 392 | let err_msg = format!("Run command with io error, {:?}", e); 393 | error!( 394 | "project \"{}\" command \"{}\" {}", 395 | runtime.proj.name(), 396 | runtime.cmd.name(), 397 | err_msg 398 | ); 399 | return runtime.proj.make_markdown_response_with_text(err_msg); 400 | } 401 | }; 402 | 403 | let mut ret_msg = String::with_capacity(output.stdout.len() + output.stderr.len() + 32); 404 | if !output.stdout.is_empty() { 405 | ret_msg += (match String::from_utf8(output.stdout) { 406 | Ok(x) => x, 407 | Err(e) => hex::encode(e.as_bytes()), 408 | }) 409 | .as_str(); 410 | } 411 | 412 | if !output.stderr.is_empty() { 413 | let stderr_str = match String::from_utf8(output.stderr) { 414 | Ok(x) => x, 415 | Err(e) => hex::encode(e.as_bytes()), 416 | }; 417 | 418 | info!( 419 | "project \"{}\" command \"{}\" run command with stderr:\n{}", 420 | runtime.proj.name(), 421 | runtime.cmd.name(), 422 | stderr_str 423 | ); 424 | ret_msg += stderr_str.as_str(); 425 | } 426 | 427 | if output.status.success() { 428 | match output_type { 429 | command::WxWorkCommandSpawnOutputType::Markdown => { 430 | runtime.proj.make_markdown_response_with_text(ret_msg) 431 | } 432 | command::WxWorkCommandSpawnOutputType::Text => { 433 | let mut mentioned_list: Vec = Vec::new(); 434 | 435 | for caps in PICK_AT_RULE.captures_iter(ret_msg.as_str()) { 436 | if let Some(m) = caps.name("AT") { 437 | mentioned_list.push(String::from(m.as_str())); 438 | } 439 | } 440 | 441 | let rsp = message::WxWorkMessageTextRsp { 442 | content: ret_msg, 443 | mentioned_list, 444 | mentioned_mobile_list: Vec::new(), 445 | }; 446 | 447 | runtime.proj.make_text_response(rsp) 448 | } 449 | command::WxWorkCommandSpawnOutputType::Image => { 450 | let file_path = ret_msg.trim(); 451 | let mut options = OpenOptions::new(); 452 | options 453 | .write(false) 454 | .create(false) 455 | .truncate(false) 456 | .read(true); 457 | let mut err_msg = String::default(); 458 | let mut image_data: Vec = Vec::new(); 459 | 460 | if !file_path.is_empty() { 461 | match options.open(file_path) { 462 | Ok(f) => { 463 | let mut reader = BufReader::new(f); 464 | match reader.read_to_end(&mut image_data) { 465 | Ok(_) => {} 466 | Err(e) => { 467 | err_msg = 468 | format!("Try read data from {} failed, {:?}", file_path, e); 469 | } 470 | } 471 | } 472 | Err(e) => { 473 | err_msg = format!("Try to open {} failed, {:?}", file_path, e); 474 | } 475 | }; 476 | } 477 | 478 | if !image_data.is_empty() { 479 | runtime 480 | .proj 481 | .make_image_response(message::WxWorkMessageImageRsp { 482 | content: image_data, 483 | }) 484 | } else { 485 | runtime 486 | .proj 487 | .make_text_response(message::WxWorkMessageTextRsp { 488 | content: err_msg, 489 | mentioned_list: vec![runtime.msg.from.alias.clone()], 490 | mentioned_mobile_list: Vec::new(), 491 | }) 492 | } 493 | } 494 | } 495 | } else { 496 | runtime 497 | .proj 498 | .make_text_response(message::WxWorkMessageTextRsp { 499 | content: ret_msg, 500 | mentioned_list: vec![runtime.msg.from.alias.clone()], 501 | mentioned_mobile_list: Vec::new(), 502 | }) 503 | } 504 | //Box::new(future_ok(runtime.proj.make_markdown_response_with_text(echo_output))) 505 | } 506 | -------------------------------------------------------------------------------- /src/wxwork_robot/command.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::sync::Arc; 3 | 4 | use regex::{Regex, RegexBuilder}; 5 | 6 | #[derive(Debug, Clone)] 7 | pub struct WxWorkCommandHelp { 8 | pub prefix: String, 9 | pub suffix: String, 10 | } 11 | 12 | #[derive(Debug, Clone)] 13 | pub struct WxWorkCommandEcho { 14 | pub echo: String, 15 | } 16 | 17 | #[derive(Debug, Clone, Copy)] 18 | pub enum WxWorkCommandSpawnOutputType { 19 | Markdown, 20 | Text, 21 | Image, 22 | } 23 | 24 | #[derive(Debug, Clone)] 25 | pub struct WxWorkCommandSpawn { 26 | pub exec: String, 27 | pub args: Vec, 28 | pub cwd: String, 29 | pub output_type: WxWorkCommandSpawnOutputType, 30 | } 31 | 32 | #[derive(Debug, Clone, Copy)] 33 | pub enum WxWorkCommandHttpMethod { 34 | Auto, 35 | Get, 36 | Post, 37 | Delete, 38 | Head, 39 | Put, 40 | } 41 | 42 | #[derive(Debug, Clone)] 43 | pub struct WxWorkCommandHttp { 44 | pub url: String, 45 | pub echo: String, 46 | pub post: String, 47 | pub method: WxWorkCommandHttpMethod, 48 | pub content_type: String, 49 | pub headers: HashMap, 50 | } 51 | 52 | #[derive(Debug, Clone)] 53 | pub enum WxWorkCommandData { 54 | Echo(Arc), 55 | Spawn(Arc), 56 | Http(Arc), 57 | Help(Arc), 58 | Ignore, 59 | } 60 | 61 | #[derive(Debug, Clone)] 62 | pub struct WxWorkCommand { 63 | pub data: WxWorkCommandData, 64 | name: Arc, 65 | pub envs: serde_json::Value, 66 | rule: Regex, 67 | pub hidden: bool, 68 | pub description: Arc, 69 | pub order: i64, 70 | } 71 | 72 | #[derive(Debug, Clone)] 73 | pub struct WxWorkCommandMatch(serde_json::Value); 74 | 75 | pub type WxWorkCommandPtr = Arc; 76 | pub type WxWorkCommandList = Vec; 77 | 78 | pub fn read_string_from_json_object(json: &serde_json::Value, name: &str) -> Option { 79 | if let Some(x) = json.as_object() { 80 | if let Some(v) = x.get(name) { 81 | if let Some(r) = v.as_str() { 82 | return Some(String::from(r)); 83 | } 84 | } 85 | } 86 | 87 | None 88 | } 89 | 90 | pub fn read_object_from_json_object<'a>( 91 | json: &'a serde_json::Value, 92 | name: &str, 93 | ) -> Option<&'a serde_json::map::Map> { 94 | if let Some(x) = json.as_object() { 95 | if let Some(v) = x.get(name) { 96 | if let Some(r) = v.as_object() { 97 | return Some(r); 98 | } 99 | } 100 | } 101 | 102 | None 103 | } 104 | 105 | pub fn read_bool_from_json_object(json: &serde_json::Value, name: &str) -> Option { 106 | if let Some(x) = json.as_object() { 107 | if let Some(v) = x.get(name) { 108 | return match v { 109 | serde_json::Value::Null => None, 110 | serde_json::Value::Bool(r) => Some(*r), 111 | serde_json::Value::Number(r) => { 112 | if let Some(rv) = r.as_i64() { 113 | Some(rv != 0) 114 | } else if let Some(rv) = r.as_u64() { 115 | Some(rv != 0) 116 | } else if let Some(rv) = r.as_f64() { 117 | Some(rv != 0.0) 118 | } else { 119 | Some(false) 120 | } 121 | } 122 | serde_json::Value::String(r) => { 123 | let lc_name = r.to_lowercase(); 124 | Some( 125 | !lc_name.is_empty() 126 | && lc_name.as_str() != "false" 127 | && lc_name.as_str() != "no" 128 | && lc_name.as_str() != "disable" 129 | && lc_name.as_str() != "disabled", 130 | ) 131 | } 132 | serde_json::Value::Array(r) => Some(!r.is_empty()), 133 | serde_json::Value::Object(r) => Some(!r.is_empty()), 134 | }; 135 | } 136 | } 137 | 138 | None 139 | } 140 | 141 | pub fn read_array_from_json_object<'a>( 142 | json: &'a serde_json::Value, 143 | name: &str, 144 | ) -> Option<&'a Vec> { 145 | if let Some(x) = json.as_object() { 146 | if let Some(v) = x.get(name) { 147 | if let Some(r) = v.as_array() { 148 | return Some(r); 149 | } 150 | } 151 | } 152 | 153 | None 154 | } 155 | 156 | pub fn read_i64_from_json_object(json: &serde_json::Value, name: &str) -> Option { 157 | if let Some(x) = json.as_object() { 158 | if let Some(v) = x.get(name) { 159 | return match v { 160 | serde_json::Value::Null => None, 161 | serde_json::Value::Bool(_) => None, 162 | serde_json::Value::Number(r) => { 163 | if let Some(rv) = r.as_i64() { 164 | Some(rv) 165 | } else if let Some(rv) = r.as_u64() { 166 | Some(rv as i64) 167 | } else if let Some(rv) = r.as_f64() { 168 | Some(rv as i64) 169 | } else { 170 | Some(0) 171 | } 172 | } 173 | serde_json::Value::String(r) => { 174 | if let Ok(rv) = r.parse::() { 175 | Some(rv) 176 | } else { 177 | None 178 | } 179 | } 180 | serde_json::Value::Array(_) => None, 181 | serde_json::Value::Object(_) => None, 182 | }; 183 | } 184 | } 185 | 186 | None 187 | } 188 | 189 | pub fn merge_envs(mut l: serde_json::Value, r: &serde_json::Value) -> serde_json::Value { 190 | if !l.is_object() { 191 | return l; 192 | } 193 | 194 | if !r.is_object() { 195 | return l; 196 | } 197 | 198 | if let Some(kvs) = r.as_object() { 199 | for (k, v) in kvs { 200 | match v { 201 | serde_json::Value::Null => { 202 | l[k] = v.clone(); 203 | } 204 | serde_json::Value::Bool(_) => { 205 | l[k] = v.clone(); 206 | } 207 | serde_json::Value::Number(_) => { 208 | l[k] = v.clone(); 209 | } 210 | serde_json::Value::String(_) => { 211 | l[k] = v.clone(); 212 | } 213 | _ => {} 214 | } 215 | } 216 | } 217 | 218 | l 219 | } 220 | 221 | impl WxWorkCommand { 222 | pub fn parse(json: &serde_json::Value) -> WxWorkCommandList { 223 | let mut ret: WxWorkCommandList = Vec::new(); 224 | 225 | if let Some(kvs) = json.as_object() { 226 | for (k, v) in kvs { 227 | let cmd_res = WxWorkCommand::new(k, v); 228 | if let Some(cmd) = cmd_res { 229 | ret.push(Arc::new(cmd)); 230 | } 231 | } 232 | } 233 | 234 | ret.sort_by(|l, r| { 235 | if l.order != r.order { 236 | l.order.cmp(&r.order) 237 | } else { 238 | l.name().cmp(&r.name()) 239 | } 240 | }); 241 | 242 | ret 243 | } 244 | 245 | pub fn new(cmd_name: &str, json: &serde_json::Value) -> Option { 246 | let cmd_data: WxWorkCommandData; 247 | let mut envs_obj = json!({}); 248 | // read_bool_from_json_object 249 | let mut reg_builder = RegexBuilder::new(cmd_name); 250 | reg_builder.case_insensitive( 251 | if let Some(v) = read_bool_from_json_object(json, "case_insensitive") { 252 | v 253 | } else { 254 | true 255 | }, 256 | ); 257 | reg_builder.multi_line( 258 | if let Some(v) = read_bool_from_json_object(json, "multi_line") { 259 | v 260 | } else { 261 | true 262 | }, 263 | ); 264 | reg_builder.unicode( 265 | if let Some(v) = read_bool_from_json_object(json, "unicode") { 266 | v 267 | } else { 268 | true 269 | }, 270 | ); 271 | reg_builder.octal(if let Some(v) = read_bool_from_json_object(json, "octal") { 272 | v 273 | } else { 274 | false 275 | }); 276 | reg_builder.dot_matches_new_line( 277 | if let Some(v) = read_bool_from_json_object(json, "dot_matches_new_line") { 278 | v 279 | } else { 280 | false 281 | }, 282 | ); 283 | let rule_obj = match reg_builder.build() { 284 | Ok(x) => x, 285 | Err(e) => { 286 | error!("command {} regex invalid: {}\n{}", cmd_name, json, e); 287 | return None; 288 | } 289 | }; 290 | 291 | { 292 | if !json.is_object() { 293 | error!( 294 | "command {} configure must be a json object, but real is {}", 295 | cmd_name, json 296 | ); 297 | return None; 298 | }; 299 | 300 | let type_name = if let Some(x) = read_string_from_json_object(json, "type") { 301 | x 302 | } else { 303 | error!("command {} configure require type: {}", cmd_name, json); 304 | return None; 305 | }; 306 | 307 | cmd_data = match type_name.to_lowercase().as_str() { 308 | "echo" => WxWorkCommandData::Echo(Arc::new(WxWorkCommandEcho { 309 | echo: if let Some(x) = read_string_from_json_object(json, "echo") { 310 | x 311 | } else { 312 | String::from("Ok") 313 | }, 314 | })), 315 | "spawn" => { 316 | let exec_field = if let Some(x) = read_string_from_json_object(json, "exec") { 317 | x 318 | } else { 319 | error!("spawn command {} requires exec: {}", cmd_name, json); 320 | return None; 321 | }; 322 | 323 | let mut args_field: Vec = Vec::new(); 324 | if let Some(arr) = read_array_from_json_object(json, "args") { 325 | for v in arr { 326 | args_field.push(match v { 327 | serde_json::Value::Null => String::default(), 328 | serde_json::Value::Bool(x) => { 329 | if *x { 330 | String::from("true") 331 | } else { 332 | String::from("false") 333 | } 334 | } 335 | serde_json::Value::Number(x) => x.to_string(), 336 | serde_json::Value::String(x) => x.clone(), 337 | x => x.to_string(), 338 | }); 339 | } 340 | } 341 | 342 | let cwd_field = if let Some(x) = read_string_from_json_object(json, "cwd") { 343 | x 344 | } else { 345 | String::default() 346 | }; 347 | 348 | WxWorkCommandData::Spawn(Arc::new(WxWorkCommandSpawn { 349 | exec: exec_field, 350 | args: args_field, 351 | cwd: cwd_field, 352 | output_type: match read_string_from_json_object(json, "output_type") { 353 | Some(x) => match x.to_lowercase().as_str() { 354 | "text" => WxWorkCommandSpawnOutputType::Text, 355 | "image" => WxWorkCommandSpawnOutputType::Image, 356 | _ => WxWorkCommandSpawnOutputType::Markdown, 357 | }, 358 | None => WxWorkCommandSpawnOutputType::Markdown, 359 | }, 360 | })) 361 | } 362 | "http" => { 363 | let url_field = if let Some(x) = read_string_from_json_object(json, "url") { 364 | x 365 | } else { 366 | error!("http command {} requires url: {}", cmd_name, json); 367 | return None; 368 | }; 369 | let echo_field = if let Some(x) = read_string_from_json_object(json, "echo") { 370 | x 371 | } else { 372 | String::from("Ok") 373 | }; 374 | let post_field = if let Some(x) = read_string_from_json_object(json, "post") { 375 | x 376 | } else { 377 | String::from("Ok") 378 | }; 379 | 380 | WxWorkCommandData::Http(Arc::new(WxWorkCommandHttp { 381 | url: url_field, 382 | echo: echo_field, 383 | post: post_field, 384 | method: match read_string_from_json_object(json, "method") { 385 | Some(x) => match x.to_lowercase().as_str() { 386 | "get" => WxWorkCommandHttpMethod::Get, 387 | "post" => WxWorkCommandHttpMethod::Post, 388 | "delete" => WxWorkCommandHttpMethod::Delete, 389 | "put" => WxWorkCommandHttpMethod::Put, 390 | "head" => WxWorkCommandHttpMethod::Head, 391 | _ => WxWorkCommandHttpMethod::Auto, 392 | }, 393 | None => WxWorkCommandHttpMethod::Auto, 394 | }, 395 | content_type: if let Some(x) = 396 | read_string_from_json_object(json, "content_type") 397 | { 398 | x 399 | } else { 400 | String::default() 401 | }, 402 | headers: if let Some(m) = read_object_from_json_object(json, "headers") { 403 | let mut res = HashMap::new(); 404 | for (k, v) in m { 405 | res.insert( 406 | k.clone(), 407 | match v { 408 | serde_json::Value::Null => String::default(), 409 | serde_json::Value::Bool(x) => { 410 | if *x { 411 | String::from("true") 412 | } else { 413 | String::from("false") 414 | } 415 | } 416 | serde_json::Value::Number(x) => x.to_string(), 417 | serde_json::Value::String(x) => x.clone(), 418 | x => x.to_string(), 419 | }, 420 | ); 421 | } 422 | 423 | res 424 | } else { 425 | HashMap::new() 426 | }, 427 | })) 428 | } 429 | "help" => WxWorkCommandData::Help(Arc::new(WxWorkCommandHelp { 430 | prefix: if let Some(x) = read_string_from_json_object(json, "prefix") { 431 | x 432 | } else { 433 | String::default() 434 | }, 435 | suffix: if let Some(x) = read_string_from_json_object(json, "suffix") { 436 | x 437 | } else { 438 | String::default() 439 | }, 440 | })), 441 | "ignore" => WxWorkCommandData::Ignore, 442 | _ => { 443 | error!("command {} configure type invalid: {}", cmd_name, json); 444 | return None; 445 | } 446 | }; 447 | 448 | if let Some(envs_kvs) = read_object_from_json_object(json, "env") { 449 | for (k, v) in envs_kvs { 450 | envs_obj[format!("WXWORK_ROBOT_CMD_{}", k).as_str().to_uppercase()] = 451 | if v.is_string() { 452 | v.clone() 453 | } else { 454 | serde_json::Value::String(v.to_string()) 455 | }; 456 | } 457 | } 458 | } 459 | 460 | Some(WxWorkCommand { 461 | data: cmd_data, 462 | name: Arc::new(String::from(cmd_name)), 463 | rule: rule_obj, 464 | envs: envs_obj, 465 | hidden: if let Some(x) = read_bool_from_json_object(json, "hidden") { 466 | x 467 | } else { 468 | false 469 | }, 470 | description: if let Some(x) = read_string_from_json_object(json, "description") { 471 | Arc::new(x) 472 | } else { 473 | Arc::new(String::default()) 474 | }, 475 | order: if let Some(x) = read_i64_from_json_object(json, "order") { 476 | x 477 | } else { 478 | 0 479 | }, 480 | }) 481 | } 482 | 483 | pub fn name(&self) -> Arc { 484 | self.name.clone() 485 | } 486 | 487 | pub fn try_capture(&self, message: &str) -> WxWorkCommandMatch { 488 | let caps = if let Some(x) = self.rule.captures(message) { 489 | x 490 | } else { 491 | return WxWorkCommandMatch(serde_json::Value::Null); 492 | }; 493 | 494 | let mut json = self.envs.clone(); 495 | json["WXWORK_ROBOT_CMD"] = 496 | serde_json::Value::String(String::from(caps.get(0).unwrap().as_str())); 497 | 498 | for key in self.rule.capture_names().flatten() { 499 | if let Some(m) = caps.name(key) { 500 | json[format!("WXWORK_ROBOT_CMD_{}", key).as_str().to_uppercase()] = 501 | serde_json::Value::String(String::from(m.as_str())); 502 | } 503 | } 504 | 505 | WxWorkCommandMatch(json) 506 | } 507 | 508 | pub fn description(&self) -> Arc { 509 | self.description.clone() 510 | } 511 | 512 | pub fn is_hidden(&self) -> bool { 513 | self.hidden 514 | } 515 | } 516 | 517 | impl WxWorkCommandMatch { 518 | pub fn has_result(&self) -> bool { 519 | self.0.is_object() 520 | } 521 | 522 | pub fn ref_json(&self) -> &serde_json::Value { 523 | &self.0 524 | } 525 | 526 | pub fn mut_json(&mut self) -> &mut serde_json::Value { 527 | &mut self.0 528 | } 529 | } 530 | 531 | pub fn get_command_description(cmd: &WxWorkCommandPtr) -> Option> { 532 | if cmd.is_hidden() { 533 | None 534 | } else { 535 | let desc = cmd.description(); 536 | if !desc.is_empty() { 537 | Some(desc) 538 | } else { 539 | Some(cmd.name()) 540 | } 541 | } 542 | } 543 | -------------------------------------------------------------------------------- /src/app.rs: -------------------------------------------------------------------------------- 1 | use std::fs::{create_dir_all, OpenOptions}; 2 | use std::io::Write; 3 | use std::net::ToSocketAddrs; 4 | use std::path::{Path, PathBuf}; 5 | use std::process; 6 | use std::rc::Rc; 7 | use std::str::FromStr; 8 | use std::sync::Arc; 9 | 10 | use crate::clap::{Arg, ArgAction, ArgMatches}; 11 | 12 | use super::wxwork_robot::command::{WxWorkCommandList, WxWorkCommandMatch, WxWorkCommandPtr}; 13 | use super::wxwork_robot::project::WxWorkProject; 14 | use super::wxwork_robot::{build_project_set_shared, WxWorkProjectSet, WxWorkProjectSetShared}; 15 | 16 | #[derive(Debug, Clone)] 17 | pub struct AppConfigure { 18 | pub task_timeout: u64, 19 | pub hosts: Option>, 20 | pub workers: usize, 21 | pub backlog: u32, 22 | pub keep_alive: u64, 23 | pub client_timeout: u64, 24 | pub client_shutdown: u64, 25 | pub max_connection_per_worker: usize, 26 | pub max_concurrent_rate_per_worker: usize, 27 | pub payload_size_limit: usize, 28 | pub static_root: Option, 29 | } 30 | 31 | #[derive(Debug, Clone, Copy)] 32 | pub struct AppEnvironment { 33 | pub appname: &'static str, 34 | pub configure: &'static str, 35 | pub version: &'static str, 36 | pub prefix: &'static str, 37 | pub debug: bool, 38 | pub log: &'static str, 39 | pub log_rotate: i32, 40 | pub log_rotate_size: usize, 41 | pub pid_file: &'static str, 42 | pub conf: &'static AppConfigure, 43 | } 44 | 45 | struct AppEnvironmentInfo { 46 | init: bool, 47 | pub debug: bool, 48 | pub configure: Option, 49 | pub prefix: Option, 50 | pub log: Option, 51 | pub log_rotate: i32, 52 | pub log_rotate_size: usize, 53 | pub pid_file: Option, 54 | pub projects: Option, 55 | pub conf: AppConfigure, 56 | } 57 | 58 | static mut APP_ENV_INFO_STORE: AppEnvironmentInfo = AppEnvironmentInfo { 59 | init: false, 60 | debug: false, 61 | configure: None, 62 | prefix: None, 63 | log: None, 64 | log_rotate: 8, 65 | log_rotate_size: 2097152, 66 | pid_file: None, 67 | projects: None, 68 | conf: AppConfigure { 69 | task_timeout: 5000, 70 | hosts: None, 71 | workers: 8, 72 | backlog: 256, 73 | keep_alive: 5, 74 | client_timeout: 5000, 75 | client_shutdown: 5000, 76 | max_connection_per_worker: 20480, 77 | max_concurrent_rate_per_worker: 256, 78 | payload_size_limit: 262144, // 256KB 79 | static_root: None, 80 | }, 81 | }; 82 | 83 | fn unwraper_flag(matches: &ArgMatches, name: S) -> bool 84 | where 85 | S: AsRef, 86 | { 87 | if let Ok(Some(x)) = matches.try_get_one::(name.as_ref()) { 88 | return *x; 89 | } 90 | 91 | false 92 | } 93 | 94 | pub trait OptionValueWrapper { 95 | fn pick(input: &str) -> Option; 96 | } 97 | 98 | impl OptionValueWrapper for T 99 | where 100 | T: FromStr, 101 | { 102 | fn pick(input: &str) -> Option { 103 | if let Ok(v) = input.parse::() { 104 | Some(v) 105 | } else { 106 | None 107 | } 108 | } 109 | } 110 | 111 | fn unwraper_option(matches: &ArgMatches, name: S) -> Option 112 | where 113 | T: OptionValueWrapper, 114 | S: AsRef, 115 | { 116 | if let Ok(Some(x)) = matches.try_get_raw(name.as_ref()) { 117 | for val in x { 118 | if let Some(str_val) = val.to_str() { 119 | return T::pick(str_val); 120 | } 121 | } 122 | } 123 | 124 | None 125 | } 126 | 127 | /// Build a clap application parameterized by usage strings. 128 | pub fn app() -> AppEnvironment { 129 | unsafe { 130 | if APP_ENV_INFO_STORE.init { 131 | return generate_app_env(); 132 | } 133 | } 134 | 135 | let matches = command!(); 136 | 137 | let app = matches 138 | .author(crate_authors!()) 139 | .version(crate_version!()) 140 | .about(crate_description!()) 141 | .max_term_width(120) 142 | .arg( 143 | Arg::new("debug") 144 | .short('d') 145 | .long("debug") 146 | .action(ArgAction::SetTrue) 147 | .help("Show debug log"), 148 | ) 149 | .arg( 150 | Arg::new("prefix") 151 | .short('P') 152 | .long("prefix") 153 | .value_name("PREFIX") 154 | .help("Set a url prefix for current service") 155 | .default_value("/"), 156 | ) 157 | .arg( 158 | Arg::new("configure") 159 | .short('c') 160 | .long("conf") 161 | .value_name("CONFIGURE") 162 | .help("Set configure file") 163 | .required(true), 164 | ) 165 | .arg( 166 | Arg::new("log") 167 | .short('l') 168 | .long("log") 169 | .value_name("LOG PATH") 170 | .help("Set log path"), 171 | ) 172 | .arg( 173 | Arg::new("log-rotate") 174 | .long("log-rotate") 175 | .value_name("LOG ROTATE") 176 | .help("Set log rotate") 177 | .default_value("8"), 178 | ) 179 | .arg( 180 | Arg::new("log-rotate-size") 181 | .long("log-rotate-size") 182 | .value_name("LOG ROTATE SIZE") 183 | .help("Set log rotate size in bytes"), 184 | ) 185 | .arg( 186 | Arg::new("pid-file") 187 | .long("pid-file") 188 | .value_name("PID FILE") 189 | .help("Set path of pid file"), 190 | ); 191 | 192 | let matches: ArgMatches = app.get_matches(); 193 | 194 | unsafe { 195 | if unwraper_flag(&matches, "debug") { 196 | APP_ENV_INFO_STORE.debug = true; 197 | } 198 | 199 | if let Some(val) = unwraper_option(&matches, "configure") { 200 | APP_ENV_INFO_STORE.configure = Some(val); 201 | } 202 | 203 | if let Some(mut val_str) = unwraper_option::(&matches, "prefix") { 204 | if !val_str.starts_with('/') { 205 | val_str.insert(0, '/'); 206 | } 207 | if !val_str.ends_with('/') { 208 | val_str.push('/'); 209 | } 210 | APP_ENV_INFO_STORE.prefix = Some(val_str); 211 | } 212 | 213 | if let Some(val) = unwraper_option(&matches, "log") { 214 | APP_ENV_INFO_STORE.log = Some(val); 215 | } else { 216 | APP_ENV_INFO_STORE.log = Some(format!("{}.log", crate_name!())); 217 | } 218 | 219 | if let Some(rotate) = unwraper_option(&matches, "log-rotate") { 220 | APP_ENV_INFO_STORE.log_rotate = rotate; 221 | } 222 | 223 | if let Some(rotate_size) = unwraper_option(&matches, "log-rotate-size") { 224 | APP_ENV_INFO_STORE.log_rotate_size = rotate_size; 225 | } 226 | 227 | if let Some(val) = unwraper_option(&matches, "pid-file") { 228 | APP_ENV_INFO_STORE.pid_file = Some(val); 229 | } else { 230 | APP_ENV_INFO_STORE.pid_file = Some(format!("{}.pid", crate_name!())); 231 | } 232 | 233 | APP_ENV_INFO_STORE.init = true; 234 | } 235 | 236 | let app = generate_app_env(); 237 | 238 | // write pid 239 | write_pid_file(app.pid_file); 240 | 241 | app 242 | } 243 | 244 | fn write_pid_file(pid_file: &str) { 245 | // get & create base dir 246 | let file_path = Path::new(pid_file); 247 | if let Some(dir_path) = file_path.parent() { 248 | if !dir_path.as_os_str().is_empty() && (!dir_path.exists() || !dir_path.is_dir()) { 249 | match create_dir_all(dir_path) { 250 | Ok(_) => {} 251 | Err(e) => { 252 | eprintln!( 253 | "Try to create pid file directory {:?} failed, {}", 254 | dir_path, e 255 | ); 256 | return; 257 | } 258 | } 259 | } 260 | } 261 | 262 | let mut options = OpenOptions::new(); 263 | options.create(true).write(true).truncate(true); 264 | match options.open(pid_file) { 265 | Ok(mut file) => match file.write(format!("{}", process::id()).as_bytes()) { 266 | Ok(_) => {} 267 | Err(e) => { 268 | eprintln!( 269 | "Try to write {} to pid file {} failed, {}", 270 | process::id(), 271 | pid_file, 272 | e 273 | ); 274 | } 275 | }, 276 | Err(e) => { 277 | eprintln!("Try to open pid file {} failed, {}", pid_file, e); 278 | } 279 | } 280 | } 281 | 282 | pub fn app_conf() -> &'static AppConfigure { 283 | let ret; 284 | unsafe { 285 | ret = &APP_ENV_INFO_STORE.conf; 286 | } 287 | 288 | ret 289 | } 290 | 291 | fn generate_app_env() -> AppEnvironment { 292 | unsafe { 293 | AppEnvironment { 294 | appname: crate_name!(), 295 | configure: if let Some(ref x) = APP_ENV_INFO_STORE.configure { 296 | x.as_str() 297 | } else { 298 | "conf.json" 299 | }, 300 | version: crate_version!(), 301 | prefix: if let Some(ref x) = APP_ENV_INFO_STORE.prefix { 302 | x.as_str() 303 | } else { 304 | "/" 305 | }, 306 | debug: APP_ENV_INFO_STORE.debug, 307 | log: if let Some(ref x) = APP_ENV_INFO_STORE.log { 308 | x.as_str() 309 | } else { 310 | "server.log" 311 | }, 312 | log_rotate: APP_ENV_INFO_STORE.log_rotate, 313 | log_rotate_size: APP_ENV_INFO_STORE.log_rotate_size, 314 | pid_file: if let Some(ref x) = APP_ENV_INFO_STORE.pid_file { 315 | x.as_str() 316 | } else { 317 | "server.pid" 318 | }, 319 | conf: &APP_ENV_INFO_STORE.conf, 320 | } 321 | } 322 | } 323 | 324 | impl AppEnvironment { 325 | fn get_info(&self, is_html: bool) -> String { 326 | let mut title = format!("{0} {1} Listen on", self.appname, self.version); 327 | for v in self.get_hosts() { 328 | title += format!(" \"{0}\"", v).as_str(); 329 | } 330 | title += format!(" with prefix {0}", self.prefix).as_str(); 331 | 332 | let header = if is_html { 333 | format!( 334 | "

{}

", 335 | title 336 | ) 337 | } else { 338 | format!("# {}", title) 339 | }; 340 | 341 | let mut prefix_str = String::from(self.prefix); 342 | prefix_str.pop(); 343 | let tail = if is_html { "
OptionValue
" } else { "" }; 344 | let row_begin = if is_html { "" } else { "" }; 345 | let row_split = if is_html { "" } else { ": " }; 346 | let row_end = if is_html { "" } else { "\n" }; 347 | let mut row_host = String::from(""); 348 | for v in self.get_hosts() { 349 | if let Ok(saddr_iter) = v.to_socket_addrs() { 350 | for saddr in saddr_iter { 351 | if saddr.is_ipv4() { 352 | row_host += 353 | format!( 354 | "{3}Wechat robot callback URL: {4}http://{0}:{1}{2}/{5}", 355 | saddr.ip(), saddr.port(), prefix_str, row_begin, row_split, row_end 356 | ) 357 | .as_str(); 358 | if saddr.ip().is_unspecified() { 359 | row_host += 360 | format!( 361 | "{3}Wechat robot callback URL: {4}http://{0}:{1}{2}/{5}", 362 | "127.0.0.1", saddr.port(), prefix_str, row_begin, row_split, row_end 363 | ).as_str(); 364 | } 365 | } else if saddr.is_ipv6() { 366 | row_host += format!( 367 | "{3}Wechat robot callback URL: {4}http://[{0}]:{1}{2}/{5}", 368 | saddr.ip(), saddr.port(), prefix_str, row_begin, row_split, row_end 369 | ).as_str(); 370 | if saddr.ip().is_unspecified() { 371 | row_host += 372 | format!( 373 | "{3}Wechat robot callback URL: {4}http://{0}:{1}{2}/{5}", 374 | "::", saddr.port(), prefix_str, row_begin, row_split, row_end 375 | ).as_str(); 376 | } 377 | } 378 | } 379 | } 380 | } 381 | format!("{0}\r\n{1}\r\n{2}", header, row_host, tail) 382 | } 383 | 384 | pub fn text_info(&self) -> String { 385 | self.get_info(false) 386 | } 387 | 388 | pub fn html_info(&self) -> String { 389 | self.get_info(true) 390 | } 391 | 392 | pub fn get_hosts(&self) -> Vec { 393 | let ret: Vec; 394 | unsafe { 395 | ret = if let Some(ref x) = APP_ENV_INFO_STORE.conf.hosts { 396 | x.clone() 397 | } else { 398 | Vec::new() 399 | }; 400 | } 401 | 402 | ret 403 | } 404 | 405 | pub fn get_projects(&self) -> Option { 406 | let ret: Option; 407 | unsafe { 408 | ret = APP_ENV_INFO_STORE.projects.as_ref().cloned(); 409 | } 410 | 411 | ret 412 | } 413 | 414 | pub fn set_projects(&self, val: WxWorkProjectSetShared) { 415 | { 416 | if let Ok(x) = val.lock() { 417 | let ref_x: &WxWorkProjectSet = &x; 418 | for (k, _) in ref_x.projs.iter() { 419 | info!("load project \"{}\" success", k); 420 | } 421 | 422 | for cmd in ref_x.cmds.iter() { 423 | info!("load global command \"{}\" success", cmd.name()); 424 | } 425 | 426 | for cmd in ref_x.events.iter() { 427 | info!("load global event \"{}\" success", cmd.name()); 428 | } 429 | } 430 | } 431 | 432 | unsafe { 433 | APP_ENV_INFO_STORE.projects = Some(val); 434 | } 435 | } 436 | 437 | pub fn get_project(&self, name: &str) -> Option> { 438 | if let Some(projs) = self.get_projects() { 439 | if let Ok(x) = projs.lock() { 440 | if let Some(found_proj) = x.projs.get(name) { 441 | return Some(found_proj.clone()); 442 | } 443 | } 444 | } 445 | 446 | None 447 | } 448 | 449 | pub fn get_global_command( 450 | &self, 451 | message: &str, 452 | allow_hidden: bool, 453 | ) -> Option<(WxWorkCommandPtr, WxWorkCommandMatch)> { 454 | if let Some(projs) = self.get_projects() { 455 | if let Ok(x) = projs.lock() { 456 | return WxWorkProject::try_capture_commands(&x.cmds, message, allow_hidden); 457 | } 458 | } 459 | 460 | None 461 | } 462 | 463 | pub fn get_global_event( 464 | &self, 465 | message: &str, 466 | allow_hidden: bool, 467 | ) -> Option<(WxWorkCommandPtr, WxWorkCommandMatch)> { 468 | if let Some(projs) = self.get_projects() { 469 | if let Ok(x) = projs.lock() { 470 | return WxWorkProject::try_capture_commands(&x.events, message, allow_hidden); 471 | } 472 | } 473 | 474 | None 475 | } 476 | 477 | /// Get global command list. 478 | /// 479 | /// **This is a high cost API** 480 | pub fn get_global_command_list(&self) -> Rc { 481 | if let Some(projs) = self.get_projects() { 482 | if let Ok(x) = projs.lock() { 483 | return x.cmds.clone(); 484 | } 485 | } 486 | 487 | Rc::new(Vec::new()) 488 | } 489 | 490 | pub fn reload(&mut self) -> bool { 491 | let mut options = OpenOptions::new(); 492 | options.read(true).write(false); 493 | match options.open(self.configure) { 494 | Ok(f) => { 495 | if let Ok(conf) = serde_json::from_reader(f) { 496 | let conf_json: serde_json::Value = conf; 497 | self.reload_app_conf(&conf_json); 498 | if let Some(x) = build_project_set_shared(&conf_json) { 499 | self.set_projects(x); 500 | } else { 501 | error!( 502 | "Build project set from configure file {} failed", 503 | self.configure 504 | ); 505 | return false; 506 | } 507 | } else { 508 | error!("Parse configure file {} as json failed", self.configure); 509 | return false; 510 | } 511 | } 512 | Err(e) => { 513 | error!("Open configure file {} failed, {:?}", self.configure, e); 514 | return false; 515 | } 516 | } 517 | 518 | true 519 | } 520 | 521 | fn reload_app_conf(&mut self, conf_json: &serde_json::Value) { 522 | let kvs = if let Some(x) = conf_json.as_object() { 523 | x 524 | } else { 525 | return; 526 | }; 527 | 528 | if let Some(x) = kvs.get("task_timeout") { 529 | if let Some(v) = x.as_u64() { 530 | if v > 0 { 531 | unsafe { 532 | APP_ENV_INFO_STORE.conf.task_timeout = v; 533 | } 534 | } 535 | } 536 | } else if let Some(x) = kvs.get("taskTimeout") { 537 | if let Some(v) = x.as_u64() { 538 | if v > 0 { 539 | unsafe { 540 | APP_ENV_INFO_STORE.conf.task_timeout = v; 541 | } 542 | } 543 | } 544 | } 545 | 546 | if let Some(x) = kvs.get("workers") { 547 | if let Some(v) = x.as_u64() { 548 | if v > 0 { 549 | unsafe { 550 | APP_ENV_INFO_STORE.conf.workers = v as usize; 551 | } 552 | } 553 | } 554 | } 555 | 556 | if let Some(x) = kvs.get("backlog") { 557 | if let Some(v) = x.as_u64() { 558 | if v > 0 { 559 | unsafe { 560 | APP_ENV_INFO_STORE.conf.backlog = v as u32; 561 | } 562 | } 563 | } 564 | } 565 | 566 | if let Some(x) = kvs.get("keep_alive") { 567 | if let Some(v) = x.as_u64() { 568 | if v > 0 { 569 | unsafe { 570 | APP_ENV_INFO_STORE.conf.keep_alive = v; 571 | } 572 | } 573 | } 574 | } 575 | 576 | if let Some(x) = kvs.get("client_timeout") { 577 | if let Some(v) = x.as_u64() { 578 | if v > 0 { 579 | unsafe { 580 | APP_ENV_INFO_STORE.conf.client_timeout = v; 581 | } 582 | } 583 | } 584 | } 585 | 586 | if let Some(x) = kvs.get("client_shutdown") { 587 | if let Some(v) = x.as_u64() { 588 | if v > 0 { 589 | unsafe { 590 | APP_ENV_INFO_STORE.conf.client_shutdown = v; 591 | } 592 | } 593 | } 594 | } 595 | 596 | if let Some(x) = kvs.get("max_connection_per_worker") { 597 | if let Some(v) = x.as_u64() { 598 | if v > 0 { 599 | unsafe { 600 | APP_ENV_INFO_STORE.conf.max_connection_per_worker = v as usize; 601 | } 602 | } 603 | } 604 | } 605 | 606 | if let Some(x) = kvs.get("max_concurrent_rate_per_worker") { 607 | if let Some(v) = x.as_u64() { 608 | if v > 0 { 609 | unsafe { 610 | APP_ENV_INFO_STORE.conf.max_concurrent_rate_per_worker = v as usize; 611 | } 612 | } 613 | } 614 | } 615 | 616 | if let Some(x) = kvs.get("payload_size_limit") { 617 | if let Some(v) = x.as_u64() { 618 | if v > 0 { 619 | unsafe { 620 | APP_ENV_INFO_STORE.conf.payload_size_limit = v as usize; 621 | } 622 | } 623 | } 624 | } 625 | 626 | if let Some(x) = kvs.get("static_root") { 627 | if let Some(v) = x.as_str() { 628 | if !v.is_empty() { 629 | if let Ok(root_path) = PathBuf::from_str(v) { 630 | unsafe { 631 | APP_ENV_INFO_STORE.conf.static_root = Some(root_path); 632 | } 633 | } 634 | } 635 | } 636 | } 637 | 638 | { 639 | let mut hosts = Vec::new(); 640 | if let Some(x) = kvs.get("listen") { 641 | if let Some(arr) = x.as_array() { 642 | for v in arr { 643 | if let Some(vh) = v.as_str() { 644 | hosts.push(String::from(vh)); 645 | } 646 | } 647 | } else if let Some(v) = x.as_str() { 648 | hosts.push(String::from(v)); 649 | } 650 | } 651 | 652 | if hosts.is_empty() { 653 | hosts.push(String::from("0.0.0.0:12019")); 654 | hosts.push(String::from(":::12019")); 655 | } 656 | 657 | unsafe { 658 | APP_ENV_INFO_STORE.conf.hosts = Some(hosts); 659 | } 660 | } 661 | } 662 | } 663 | --------------------------------------------------------------------------------