├── .github └── workflows │ ├── pylint.yml │ └── python-package.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── README_zh_CN.md ├── ailingbot ├── __init__.py ├── channels │ ├── __init__.py │ ├── channel.py │ ├── dingtalk │ │ ├── __init__.py │ │ ├── agent.py │ │ ├── render.py │ │ └── webhook.py │ ├── feishu │ │ ├── __init__.py │ │ ├── agent.py │ │ ├── render.py │ │ └── webhook.py │ ├── slack │ │ ├── __init__.py │ │ ├── agent.py │ │ └── webhook.py │ └── wechatwork │ │ ├── __init__.py │ │ ├── agent.py │ │ ├── encrypt.py │ │ ├── render.py │ │ └── webhook.py ├── chat │ ├── __init__.py │ ├── chatbot.py │ ├── messages.py │ ├── policies │ │ ├── __init__.py │ │ ├── conversation.py │ │ └── document_qa.py │ └── policy.py ├── cli │ ├── __init__.py │ ├── cli.py │ ├── options.py │ └── render.py ├── config.py ├── endpoint │ ├── __init__.py │ ├── model.py │ └── server.py └── shared │ ├── __init__.py │ ├── abc.py │ ├── errors.py │ └── misc.py ├── img ├── command-line-screenshot.png ├── dingtalk-screenshot.png ├── feishu-screenshot.png ├── flow.png ├── logo.png ├── slack-screenshot.png ├── swagger.png └── wechatwork-screenshot.png ├── poetry.lock ├── pyproject.toml └── tests ├── __init__.py ├── conftest.py └── shared ├── __init__.py └── test_misc.py /.github/workflows/pylint.yml: -------------------------------------------------------------------------------- 1 | name: Pylint 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | python-version: ["3.9", "3.10", "3.11"] 11 | steps: 12 | - uses: actions/checkout@v3 13 | - name: Set up Python ${{ matrix.python-version }} 14 | uses: actions/setup-python@v3 15 | with: 16 | python-version: ${{ matrix.python-version }} 17 | - name: Install dependencies 18 | run: | 19 | python -m pip install --upgrade pip 20 | pip install pylint 21 | - name: Analysing the code with pylint 22 | run: | 23 | pylint $(git ls-files '*.py') 24 | -------------------------------------------------------------------------------- /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python 3 | 4 | name: Python package 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | python-version: [ "3.9", "3.10", "3.11" ] 20 | 21 | steps: 22 | - uses: actions/checkout@v3 23 | - name: Set up Python ${{ matrix.python-version }} 24 | uses: actions/setup-python@v3 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install --upgrade pip 30 | python -m pip install poetry 31 | poetry install --with=dev 32 | - name: Lint with flake8 33 | run: | 34 | # stop the build if there are Python syntax errors or undefined names 35 | poetry run flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 36 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 37 | poetry run flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 38 | - name: Test with pytest 39 | run: | 40 | # poetry run pytest 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### JetBrains template 2 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 3 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 4 | 5 | # Vector databases 6 | .chroma/ 7 | 8 | # Pycharm 9 | .idea/ 10 | 11 | # Config files 12 | settings.toml 13 | 14 | # User-specific stuff 15 | .idea/**/workspace.xml 16 | .idea/**/tasks.xml 17 | .idea/**/usage.statistics.xml 18 | .idea/**/dictionaries 19 | .idea/**/shelf 20 | 21 | # Generated files 22 | .idea/**/contentModel.xml 23 | 24 | # Sensitive or high-churn files 25 | .idea/**/dataSources/ 26 | .idea/**/dataSources.ids 27 | .idea/**/dataSources.local.xml 28 | .idea/**/sqlDataSources.xml 29 | .idea/**/dynamic.xml 30 | .idea/**/uiDesigner.xml 31 | .idea/**/dbnavigator.xml 32 | 33 | # Gradle 34 | .idea/**/gradle.xml 35 | .idea/**/libraries 36 | 37 | # Gradle and Maven with auto-import 38 | # When using Gradle or Maven with auto-import, you should exclude module files, 39 | # since they will be recreated, and may cause churn. Uncomment if using 40 | # auto-import. 41 | # .idea/artifacts 42 | # .idea/compiler.xml 43 | # .idea/jarRepositories.xml 44 | # .idea/modules.xml 45 | # .idea/*.iml 46 | # .idea/modules 47 | # *.iml 48 | # *.ipr 49 | 50 | # CMake 51 | cmake-build-*/ 52 | 53 | # Mongo Explorer plugin 54 | .idea/**/mongoSettings.xml 55 | 56 | # File-based project format 57 | *.iws 58 | 59 | # IntelliJ 60 | out/ 61 | 62 | # mpeltonen/sbt-idea plugin 63 | .idea_modules/ 64 | 65 | # JIRA plugin 66 | atlassian-ide-plugin.xml 67 | 68 | # Cursive Clojure plugin 69 | .idea/replstate.xml 70 | 71 | # Crashlytics plugin (for Android Studio and IntelliJ) 72 | com_crashlytics_export_strings.xml 73 | crashlytics.properties 74 | crashlytics-build.properties 75 | fabric.properties 76 | 77 | # Editor-based Rest Client 78 | .idea/httpRequests 79 | 80 | # Android studio 3.1+ serialized cache file 81 | .idea/caches/build_file_checksums.ser 82 | 83 | ### macOS template 84 | # General 85 | .DS_Store 86 | .AppleDouble 87 | .LSOverride 88 | 89 | # Icon must end with two \r 90 | Icon 91 | 92 | # Thumbnails 93 | ._* 94 | 95 | # Files that might appear in the root of a volume 96 | .DocumentRevisions-V100 97 | .fseventsd 98 | .Spotlight-V100 99 | .TemporaryItems 100 | .Trashes 101 | .VolumeIcon.icns 102 | .com.apple.timemachine.donotpresent 103 | 104 | # Directories potentially created on remote AFP share 105 | .AppleDB 106 | .AppleDesktop 107 | Network Trash Folder 108 | Temporary Items 109 | .apdisk 110 | 111 | ### Python template 112 | # Byte-compiled / optimized / DLL files 113 | __pycache__/ 114 | *.py[cod] 115 | *$py.class 116 | 117 | # C extensions 118 | *.so 119 | 120 | # Distribution / packaging 121 | .Python 122 | build/ 123 | develop-eggs/ 124 | dist/ 125 | downloads/ 126 | eggs/ 127 | .eggs/ 128 | lib/ 129 | lib64/ 130 | parts/ 131 | sdist/ 132 | var/ 133 | wheels/ 134 | share/python-wheels/ 135 | *.egg-info/ 136 | .installed.cfg 137 | *.egg 138 | MANIFEST 139 | 140 | # PyInstaller 141 | # Usually these files are written by a python script from a template 142 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 143 | *.manifest 144 | *.spec 145 | 146 | # Installer logs 147 | pip-log.txt 148 | pip-delete-this-directory.txt 149 | 150 | # Unit test / coverage reports 151 | htmlcov/ 152 | .tox/ 153 | .nox/ 154 | .coverage 155 | .coverage.* 156 | .cache 157 | nosetests.xml 158 | coverage.xml 159 | *.cover 160 | *.py,cover 161 | .hypothesis/ 162 | .pytest_cache/ 163 | cover/ 164 | 165 | # Translations 166 | *.mo 167 | *.pot 168 | 169 | # Django stuff: 170 | *.log 171 | local_settings.py 172 | db.sqlite3 173 | db.sqlite3-journal 174 | 175 | # Flask stuff: 176 | instance/ 177 | .webassets-cache 178 | 179 | # Scrapy stuff: 180 | .scrapy 181 | 182 | # Sphinx documentation 183 | docs/_build/ 184 | 185 | # PyBuilder 186 | .pybuilder/ 187 | target/ 188 | 189 | # Jupyter Notebook 190 | .ipynb_checkpoints 191 | 192 | # IPython 193 | profile_default/ 194 | ipython_config.py 195 | 196 | # pyenv 197 | # For a library or package, you might want to ignore these files since the code is 198 | # intended to run in multiple environments; otherwise, check them in: 199 | # .python-version 200 | 201 | # pipenv 202 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 203 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 204 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 205 | # install all needed dependencies. 206 | #Pipfile.lock 207 | 208 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 209 | __pypackages__/ 210 | 211 | # Celery stuff 212 | celerybeat-schedule 213 | celerybeat.pid 214 | 215 | # SageMath parsed files 216 | *.sage.py 217 | 218 | # Environments 219 | .env 220 | .venv 221 | env/ 222 | venv/ 223 | ENV/ 224 | env.bak/ 225 | venv.bak/ 226 | 227 | # Spyder project settings 228 | .spyderproject 229 | .spyproject 230 | 231 | # Rope project settings 232 | .ropeproject 233 | 234 | # mkdocs documentation 235 | /site 236 | 237 | # mypy 238 | .mypy_cache/ 239 | .dmypy.json 240 | dmypy.json 241 | 242 | # Pyre type checker 243 | .pyre/ 244 | 245 | # pytype static type analyzer 246 | .pytype/ 247 | 248 | # Cython debug symbols 249 | cython_debug/ 250 | 251 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9.17-slim-bullseye 2 | 3 | WORKDIR /app 4 | 5 | ENV PYTHONDONTWRITEBYTECODE 1 6 | ENV PYTHONUNBUFFERED 1 7 | 8 | # Install Poetry 9 | RUN apt-get update && apt-get install gcc g++ curl build-essential postgresql-server-dev-all -y 10 | RUN curl -sSL https://install.python-poetry.org | python3 - 11 | 12 | # Add Poetry to PATH 13 | ENV PATH="${PATH}:/root/.local/bin" 14 | 15 | # Copy the pyproject.toml and poetry.lock files 16 | COPY poetry.lock pyproject.toml ./ 17 | 18 | # Copy the rest of the application codes 19 | COPY ./ ./ 20 | 21 | # Install dependencies 22 | RUN poetry config virtualenvs.create false && poetry install --no-interaction --no-ansi 23 | 24 | # Create default configuration file. 25 | RUN poetry run ailingbot init --silence --overwrite -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 AilingBot 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 🇬🇧[English](https://github.com/ericzhang-cn/ailingbot/blob/main/README.md) 2 | 🇨🇳[简体中文](https://github.com/ericzhang-cn/ailingbot/blob/main/README_zh_CN.md) 3 | 4 | --- 5 | 6 | ![Python package workflow](https://github.com/ericzhang-cn/ailingbot/actions/workflows/python-package.yml/badge.svg) 7 | ![Pylint workflow](https://github.com/ericzhang-cn/ailingbot/actions/workflows/pylint.yml/badge.svg) 8 | ![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg) 9 | 10 |

11 | AilingBot 12 |

13 | 14 |

AilingBot - One-stop solution to empower your IM bot with AI.

15 | 16 | # Table of Contents 17 | 18 | * [What is AilingBot](#what-is-ailingbot) 19 | * [Features](#features) 20 | * [Quick Start](#-quick-start) 21 | * [Start an AI chatbot in 5 minutes](#start-an-ai-chatbot-in-5-minutes) 22 | * [Using Docker](#using-docker) 23 | * [Using PIP](#using-pip) 24 | * [Installation](#installation) 25 | * [Generate Configuration File](#generate-configuration-file) 26 | * [Start the Chatbot](#start-the-chatbot) 27 | * [Start API Service](#start-api-service) 28 | * [Using Docker](#using-docker-1) 29 | * [Using PIP](#using-pip-1) 30 | * [Installation](#installation-1) 31 | * [Generate Configuration File](#generate-configuration-file-1) 32 | * [Start the Service](#start-the-service) 33 | * [Integrating with WeChat Work](#integrating-with-wechat-work) 34 | * [Using Docker](#using-docker-2) 35 | * [Using PIP](#using-pip-2) 36 | * [Installation](#installation-2) 37 | * [Generate Configuration File](#generate-configuration-file-2) 38 | * [Modify Configuration File](#modify-configuration-file) 39 | * [Start the Service](#start-the-service-1) 40 | * [Integrating with Feishu](#integrating-with-feishu) 41 | * [Using Docker](#using-docker-3) 42 | * [Using PIP](#using-pip-3) 43 | * [Installation](#installation-3) 44 | * [Generate Configuration File](#generate-configuration-file-3) 45 | * [Modify Configuration File](#modify-configuration-file-1) 46 | * [Start the Service](#start-the-service-2) 47 | * [Integrating with DingTalk](#integrating-with-dingtalk) 48 | * [Using Docker](#using-docker-4) 49 | * [Using PIP](#using-pip-4) 50 | * [Installation](#installation-4) 51 | * [Generate Configuration File](#generate-configuration-file-4) 52 | * [Modify Configuration File](#modify-configuration-file-2) 53 | * [Start the Service](#start-the-service-3) 54 | * [Integrating with Slack](#integrating-with-slack) 55 | * [Using Docker](#using-docker-5) 56 | * [Using PIP](#using-pip-5) 57 | * [Installation](#installation-5) 58 | * [Generate Configuration File](#generate-configuration-file-5) 59 | * [Modify Configuration File](#modify-configuration-file-3) 60 | * [Start the Service](#start-the-service-4) 61 | * [📖User Guide](#user-guide) 62 | * [Main Process](#main-process) 63 | * [Main Concepts](#main-concepts) 64 | * [Configuration](#configuration) 65 | * [Configuration Methods](#configuration-methods) 66 | * [Configuration Mapping](#configuration-mapping) 67 | * [Configuration Items](#configuration-items) 68 | * [General](#general) 69 | * [Built-in Policy Configuration](#built-in-policy-configuration) 70 | * [conversation](#conversation) 71 | * [document_qa](#document_qa) 72 | * [Model Configuration](#model-configuration) 73 | * [OpenAI](#openai) 74 | * [Command Line Tools](#command-line-tools) 75 | * [Initialize Configuration File (init)](#initialize-configuration-file-init) 76 | * [Usage](#usage) 77 | * [Options](#options) 78 | * [View Current Configuration (config)](#view-current-configuration-config) 79 | * [Usage](#usage-1) 80 | * [Options](#options-1) 81 | * [Start Command Line Bot (chat)](#start-command-line-bot-chat) 82 | * [Usage](#usage-2) 83 | * [Options](#options-2) 84 | * [Start Webhook Service (serve)](#start-webhook-service-serve) 85 | * [Usage](#usage-3) 86 | * [Options](#options-3) 87 | * [Start API Service (api)](#start-api-service-api) 88 | * [Usage](#usage-4) 89 | * [Options](#options-4) 90 | * [🔌API](#api) 91 | * [💻Development Guide](#development-guide) 92 | * [Development Guidelines](#development-guidelines) 93 | * [Developing Chat Policy](#developing-chat-policy) 94 | * [Developing Channel](#developing-channel) 95 | * [🤔Frequently Asked Questions](#frequently-asked-questions) 96 | * [🎯Roadmap](#roadmap) 97 | 98 | # What is AilingBot 99 | 100 | AilingBot is an open-source engineering development framework and an all-in-one solution for integrating AI models into 101 | IM chatbots. With AilingBot, you can: 102 | 103 | - ☕ **Code-free usage**: Quickly integrate existing AI large-scale models into mainstream IM chatbots (such as WeChat 104 | Work, Feishu, DingTalk, Slack etc.) to interact with AI models through IM chatbots and complete business 105 | requirements. Currently, AilingBot has built-in capabilities for multi-turn dialogue and document knowledge Q&A, and 106 | more capabilities will be added in the future. 107 | - 🛠️**Secondary development**: AilingBot provides a clear engineering architecture, interface definition, and necessary 108 | basic components. You do not need to develop the engineering framework for large-scale model services from scratch. 109 | You only need to implement your Chat Policy and complete end-to-end AI model empowerment to IM chatbots through simple 110 | configurations. It also supports expanding to your own end (such as your own IM, web application, or mobile 111 | application) by developing your own channel. 112 | 113 | # Features 114 | 115 | - 💯 **Open source & Free**: Completely open source and free. 116 | - 📦 **Ready to use**: No need for development, with pre-installed capabilities to integrate with existing mainstream IM 117 | and AI models. 118 | - 🔗 **LangChain Friendly**: Easy to integrate with LangChain. 119 | - 🧩 **Modular**: The project is organized in a modular way, with modules dependent on each other through abstract 120 | protocols. Modules of the same type can be implemented by implementing the protocol, allowing for plug-and-play. 121 | - 💻 **Extensible**: AilingBot can be extended to new usage scenarios and capabilities. For example, integrating with new 122 | IMs, new AI models, or customizing your own chat policy. 123 | - 🔥 **High performance**: AilingBot uses a coroutine-based asynchronous mode to improve system concurrency performance. 124 | At the same time, system concurrency processing capabilities can be further improved through multi-processes. 125 | - 🔌 **API Integration**: AilingBot provides a set of clear API interfaces for easy integration and collaboration with 126 | other systems and processes. 127 | 128 | # 🚀 Quick Start 129 | 130 | ## Start an AI chatbot in 5 minutes 131 | 132 | Below is a guide on how to quickly start an AI chatbot based on the command-line interface using AilingBot. The effect 133 | is shown in the following figure: 134 |

135 | Command-line chatbot 136 |

137 | 138 | > 💡 First, you need to have an OpenAI API key. If you don't have one, refer to relevant materials on the Internet to 139 | > obtain it. 140 | 141 | ### Using Docker 142 | 143 | ```shell 144 | git clone https://github.com/ericzhang-cn/ailingbot.git ailingbot 145 | cd ailingbot 146 | docker build -t ailingbot . 147 | docker run -it --rm \ 148 | -e AILINGBOT_POLICY__LLM__OPENAI_API_KEY={your OpenAI API key} \ 149 | ailingbot poetry run ailingbot chat 150 | ``` 151 | 152 | ### Using PIP 153 | 154 | #### Installation 155 | 156 | ```shell 157 | pip install ailingbot 158 | ``` 159 | 160 | #### Generate Configuration File 161 | 162 | ```shell 163 | ailingbot init --silence --overwrite 164 | ``` 165 | 166 | This will create a file called `settings.toml` in the current directory, which is the configuration file for AilingBot. 167 | Next, modify the necessary configurations. To start the bot, only one configuration is needed. Find the following 168 | section in `settings.toml`: 169 | 170 | ```toml 171 | [policy.llm] 172 | _type = "openai" 173 | model_name = "gpt-3.5-turbo" 174 | openai_api_key = "" 175 | temperature = 0 176 | ``` 177 | 178 | Change the value of `openai_api_key` to your actual OpenAI API key. 179 | 180 | #### Start the Chatbot 181 | 182 | Start the chatbot with the following command: 183 | 184 | ```shell 185 | ailingbot chat 186 | ``` 187 | 188 | ## Start API Service 189 | 190 | ### Using Docker 191 | 192 | ```shell 193 | git clone https://github.com/ericzhang-cn/ailingbot.git ailingbot 194 | cd ailingbot 195 | docker build -t ailingbot . 196 | docker run -it --rm \ 197 | -e AILINGBOT_POLICY__LLM__OPENAI_API_KEY={your OpenAI API key} \ 198 | -p 8080:8080 \ 199 | ailingbot poetry run ailingbot api 200 | ``` 201 | 202 | ### Using PIP 203 | 204 | #### Installation 205 | 206 | ```shell 207 | pip install ailingbot 208 | ``` 209 | 210 | #### Generate Configuration File 211 | 212 | Same as starting the command line bot. 213 | 214 | #### Start the Service 215 | 216 | Start the bot using the following command: 217 | 218 | ```shell 219 | ailingbot api 220 | ``` 221 | 222 | Now, enter `http://localhost:8080/docs` in your browser to see the API documentation. (If it is not a local start, 223 | please enter `http://{your public IP}:8080/docs`) 224 | 225 |

226 | Swagger API Documentation 227 |

228 | 229 | Here is an example request: 230 | 231 | ```shell 232 | curl -X 'POST' \ 233 | 'http://localhost:8080/chat/' \ 234 | -H 'accept: application/json' \ 235 | -H 'Content-Type: application/json' \ 236 | -d '{ 237 | "text": "你好" 238 | }' 239 | ``` 240 | 241 | And the response: 242 | 243 | ```json 244 | { 245 | "type": "text", 246 | "conversation_id": "default_conversation", 247 | "uuid": "afb35218-2978-404a-ab39-72a9db6f303b", 248 | "ack_uuid": "3f09933c-e577-49a5-8f56-fa328daa136f", 249 | "receiver_id": "anonymous", 250 | "scope": "user", 251 | "meta": {}, 252 | "echo": {}, 253 | "text": "你好!很高兴和你聊天。有什么我可以帮助你的吗?", 254 | "reason": null, 255 | "suggestion": null 256 | } 257 | ``` 258 | 259 | ## Integrating with WeChat Work 260 | 261 | Here's a guide on how to quickly integrate the chatbot with WeChat Work. 262 | 263 | ### Using Docker 264 | 265 | ```shell 266 | git clone https://github.com/ericzhang-cn/ailingbot.git ailingbot 267 | cd ailingbot 268 | docker build -t ailingbot . 269 | docker run -d \ 270 | -e AILINGBOT_POLICY__NAME=conversation \ 271 | -e AILINGBOT_POLICY__HISTORY_SIZE=5 \ 272 | -e AILINGBOT_POLICY__LLM__OPENAI_API_KEY={your OpenAI API key} \ 273 | -e AILINGBOT_CHANNEL__NAME=wechatwork \ 274 | -e AILINGBOT_CHANNEL__CORPID={your WeChat Work corpid} \ 275 | -e AILINGBOT_CHANNEL__CORPSECRET={your WeChat Work corpsecret} \ 276 | -e AILINGBOT_CHANNEL__AGENTID={your WeChat Work agentid} \ 277 | -e AILINGBOT_CHANNEL__TOKEN={your WeChat Work webhook token} \ 278 | -e AILINGBOT_CHANNEL__AES_KEY={your WeChat Work webhook aes_key} \ 279 | -p 8080:8080 \ 280 | ailingbot poetry run ailingbot serve 281 | ``` 282 | 283 | ### Using PIP 284 | 285 | #### Installation 286 | 287 | ```shell 288 | pip install ailingbot 289 | ``` 290 | 291 | #### Generate Configuration File 292 | 293 | ```shell 294 | ailingbot init --silence --overwrite 295 | ``` 296 | 297 | #### Modify Configuration File 298 | 299 | Open `settings.toml`, and fill in the following section with your WeChat Work robot's real information: 300 | 301 | ```toml 302 | [channel] 303 | name = "wechatwork" 304 | corpid = "" # Fill in with real information 305 | corpsecret = "" # Fill in with real information 306 | agentid = 0 # Fill in with real information 307 | token = "" # Fill in with real information 308 | aes_key = "" # Fill in with real information 309 | ``` 310 | 311 | In the `llm` section, fill in your OpenAI API Key: 312 | 313 | ```toml 314 | [policy.llm] 315 | _type = "openai" 316 | model_name = "gpt-3.5-turbo" 317 | openai_api_key = "" # Fill in with your real OpenAI API Key here 318 | temperature = 0 319 | ``` 320 | 321 | #### Start the Service 322 | 323 | ```shell 324 | ailingbot serve 325 | ``` 326 | 327 | Finally, we need to go to the WeChat Work admin console to configure the webhook address so that WeChat Work knows to 328 | forward the received user messages to our webhook. 329 | The webhook URL is: `http(s)://your_public_IP:8080/webhook/wechatwork/event/` 330 | 331 | After completing the above configuration, you can find the chatbot in WeChat Work and start chatting: 332 | 333 |

334 | WeChat Work chatbot 335 |

336 | 337 | ## Integrating with Feishu 338 | 339 | Here's a guide on how to quickly integrate the chatbot with Feishu and enable a new conversation policy: uploading 340 | documents and performing knowledge-based question answering on them. 341 | 342 | ### Using Docker 343 | 344 | ```shell 345 | git clone https://github.com/ericzhang-cn/ailingbot.git ailingbot 346 | cd ailingbot 347 | docker build -t ailingbot . 348 | docker run -d \ 349 | -e AILINGBOT_POLICY__NAME=document_qa \ 350 | -e AILINGBOT_POLICY__CHUNK_SIZE=1000 \ 351 | -e AILINGBOT_POLICY__CHUNK_OVERLAP=0 \ 352 | -e AILINGBOT_POLICY__LLM__OPENAI_API_KEY={your OpenAI API key} \ 353 | -e AILINGBOT_POLICY__LLM__MODEL_NAME=gpt-3.5-turbo-16k \ 354 | -e AILINGBOT_CHANNEL__NAME=feishu \ 355 | -e AILINGBOT_CHANNEL__APP_ID={your Feishu app id} \ 356 | -e AILINGBOT_CHANNEL__APP_SECRET={your Feishu app secret} \ 357 | -e AILINGBOT_CHANNEL__VERIFICATION_TOKEN={your Feishu webhook verification token} \ 358 | -p 8080:8080 \ 359 | ailingbot poetry run ailingbot serve 360 | ``` 361 | 362 | ### Using PIP 363 | 364 | #### Installation 365 | 366 | ```shell 367 | pip install ailingbot 368 | ``` 369 | 370 | #### Generate Configuration File 371 | 372 | ```shell 373 | ailingbot init --silence --overwrite 374 | ``` 375 | 376 | #### Modify Configuration File 377 | 378 | Open `settings.toml`, and change the `channel` section to the following, filling in your Feishu robot's real 379 | information: 380 | 381 | ```toml 382 | [channel] 383 | name = "feishu" 384 | app_id = "" # Fill in with real information 385 | app_secret = "" # Fill in with real information 386 | verification_token = "" # Fill in with real information 387 | ``` 388 | 389 | Replace the `policy` section with the following document QA policy: 390 | 391 | ```toml 392 | [policy] 393 | name = "document_qa" 394 | chunk_size = 1000 395 | chunk_overlap = 5 396 | ``` 397 | 398 | Finally, it is recommended to use the 16k model when using the document QA policy. Therefore, 399 | change `policy.llm.model_name` to the following configuration: 400 | 401 | ```toml 402 | [policy.llm] 403 | _type = "openai" 404 | model_name = "gpt-3.5-turbo-16k" # Change to gpt-3.5-turbo-16k 405 | openai_api_key = "" # Fill in with real information 406 | temperature = 0 407 | ``` 408 | 409 | #### Start the Service 410 | 411 | ```shell 412 | ailingbot serve 413 | ``` 414 | 415 | Finally, we need to go to the Feishu admin console to configure the webhook address. 416 | The webhook URL for Feishu is: `http(s)://your_public_IP:8080/webhook/feishu/event/` 417 | 418 | After completing the above configuration, you can find the chatbot in Feishu and start chatting: 419 | 420 |

421 | Feishu chatbot 422 |

423 | 424 | ## Integrating with DingTalk 425 | 426 | Here's a guide on how to quickly integrate the chatbot with DingTalk. 427 | 428 | ### Using Docker 429 | 430 | ```shell 431 | git clone https://github.com/ericzhang-cn/ailingbot.git ailingbot 432 | cd ailingbot 433 | docker build -t ailingbot . 434 | docker run -d \ 435 | -e AILINGBOT_POLICY__NAME=conversation \ 436 | -e AILINGBOT_POLICY__HISTORY_SIZE=5 \ 437 | -e AILINGBOT_POLICY__LLM__OPENAI_API_KEY={your OpenAI API key} \ 438 | -e AILINGBOT_CHANNEL__NAME=dingtalk \ 439 | -e AILINGBOT_CHANNEL__APP_KEY={your DingTalk app key} \ 440 | -e AILINGBOT_CHANNEL__APP_SECRET={your DingTalk app secret} \ 441 | -e AILINGBOT_CHANNEL__ROBOT_CODE={your DingTalk robot code} \ 442 | -p 8080:8080 \ 443 | ailingbot poetry run ailingbot serve 444 | ``` 445 | 446 | ### Using PIP 447 | 448 | #### Installation 449 | 450 | ```shell 451 | pip install ailingbot 452 | ``` 453 | 454 | #### Generate Configuration File 455 | 456 | ```shell 457 | ailingbot init --silence --overwrite 458 | ``` 459 | 460 | #### Modify Configuration File 461 | 462 | Open `settings.toml`, and change the `channel` section to the following, filling in your DingTalk robot's real 463 | information: 464 | 465 | ```toml 466 | [channel] 467 | name = "dingtalk" 468 | app_key = "" # Fill in with real information 469 | app_secret = "" # Fill in with real information 470 | robot_code = "" # Fill in with real information 471 | ``` 472 | 473 | #### Start the Service 474 | 475 | ```shell 476 | ailingbot serve 477 | ``` 478 | 479 | Finally, we need to go to the DingTalk admin console to configure the webhook address. 480 | The webhook URL for DingTalk is: `http(s)://your_public_IP:8080/webhook/dingtalk/event/` 481 | 482 | After completing the above configuration, you can find the chatbot in DingTalk and start chatting: 483 | 484 |

485 | DingTalk chatbot 486 |

487 | 488 | ## Integrating with Slack 489 | 490 | Here's a guide on how to quickly integrate the chatbot with Slack and enable a new conversation policy: uploading 491 | documents and performing knowledge-based question answering on them. 492 | 493 | ### Using Docker 494 | 495 | ```shell 496 | git clone https://github.com/ericzhang-cn/ailingbot.git ailingbot 497 | cd ailingbot 498 | docker build -t ailingbot . 499 | docker run -d \ 500 | -e AILINGBOT_POLICY__NAME=document_qa \ 501 | -e AILINGBOT_POLICY__CHUNK_SIZE=1000 \ 502 | -e AILINGBOT_POLICY__CHUNK_OVERLAP=0 \ 503 | -e AILINGBOT_POLICY__LLM__OPENAI_API_KEY={your OpenAI API key} \ 504 | -e AILINGBOT_POLICY__LLM__MODEL_NAME=gpt-3.5-turbo-16k \ 505 | -e AILINGBOT_CHANNEL__NAME=slack \ 506 | -e AILINGBOT_CHANNEL__VERIFICATION_TOKEN={your Slack App webhook verification token} \ 507 | -e AILINGBOT_CHANNEL__OAUTH_TOKEN={your Slack App oauth token} \ 508 | -p 8080:8080 \ 509 | ailingbot poetry run ailingbot serve 510 | ``` 511 | 512 | ### Using PIP 513 | 514 | #### Installation 515 | 516 | ```shell 517 | pip install ailingbot 518 | ``` 519 | 520 | #### Generate Configuration File 521 | 522 | ```shell 523 | ailingbot init --silence --overwrite 524 | ``` 525 | 526 | #### Modify Configuration File 527 | 528 | Open `settings.toml`, and change the `channel` section to the following, filling in your Slack robot's real information: 529 | 530 | ```toml 531 | [channel] 532 | name = "slack" 533 | verification_token = "" # Fill in with real information 534 | oauth_token = "" # Fill in with real information 535 | ``` 536 | 537 | Replace the `policy` section with the following document QA policy: 538 | 539 | ```toml 540 | [policy] 541 | name = "document_qa" 542 | chunk_size = 1000 543 | chunk_overlap = 5 544 | ``` 545 | 546 | Finally, it is recommended to use the 16k model when using the document QA policy. Therefore, 547 | change `policy.llm.model_name` to the following configuration: 548 | 549 | ```toml 550 | [policy.llm] 551 | _type = "openai" 552 | model_name = "gpt-3.5-turbo-16k" # Change to gpt-3.5-turbo-16k 553 | openai_api_key = "" # Fill in with real information 554 | temperature = 0 555 | ``` 556 | 557 | #### Start the Service 558 | 559 | ```shell 560 | ailingbot serve 561 | ``` 562 | 563 | Finally, we need to go to the Slack admin console to configure the webhook address. 564 | The webhook URL for Slack is: `http(s)://your_public_IP:8080/webhook/slack/event/` 565 | 566 | After completing the above configuration, you can find the chatbot in Slack and start chatting: 567 | 568 |

569 | Slack chatbot 570 |

571 | 572 | # 📖User Guide 573 | 574 | ## Main Process 575 | 576 | The main processing flow of AilingBot is as follows: 577 | 578 |

579 | Main Process 580 |

581 | 582 | 1. First, the user sends a message to the IM bot. 583 | 2. If a webhook is configured, the instant messaging tool will forward the request sent to the bot to the webhook 584 | service address. 585 | 3. The webhook service processes the original IM message and converts it into AilingBot's internal message format, which 586 | is then sent to ChatBot. 587 | 4. ChatBot processes the request and forms a response message based on the configured chat policy. During this process, 588 | ChatBot may perform operations such as requesting a large language model, accessing a vector database, or calling an 589 | external API to complete the request processing. 590 | 5. ChatBot sends the response message to the IM Agent. The IM Agent is responsible for converting the AilingBot internal 591 | response message format into a specific IM format and calling the IM open capability API to send the response 592 | message. 593 | 6. The IM bot displays the message to the user, completing the entire processing process. 594 | 595 | ## Main Concepts 596 | 597 | - **IM bot**: A capability built into most instant messaging tools that allows administrators to create a bot and 598 | process user messages through a program. 599 | - **Channel**: A channel represents different terminals, which can be an IM or a custom terminal (such as the web). 600 | - **Webhook**: An HTTP(S) service used to receive user messages forwarded by IM bots. Different channels have their own 601 | specifications for webhooks, so each channel requires its own webhook implementation. 602 | - **IM Agent**: Used to call IM open capability APIs. Different IM open capability APIs are different, so each channel 603 | requires a corresponding agent implementation. 604 | - **ChatBot**: The core component used to receive and respond to user messages. 605 | - **Chat Policy**: Defines how to respond to users and is called by ChatBot. A chat policy specifically defines the 606 | robot's abilities, such as chitchat or knowledge Q&A. 607 | - **LLM**: Large language model, such as OpenAI's ChatGPT and open ChatGLM, are all different large language models. The 608 | large language model is a key component for implementing AI capabilities. 609 | 610 | ## Configuration 611 | 612 | ### Configuration Methods 613 | 614 | AilingBot can be configured in two ways: 615 | 616 | - **Using configuration files**: AilingBot reads `settings.toml` in the current directory as the configuration file 617 | in [TOML](https://toml.io/en/) format. Please refer to the following section for specific configuration items. 618 | - **Using environment variables**: AilingBot also reads configuration items in environment variables. Please refer to 619 | the following section for a list of environment variables. 620 | 621 | > 💡 Both configuration files and environment variables can be used together. If a configuration item exists in both, the 622 | > environment variable takes precedence. 623 | 624 | ### Configuration Mapping 625 | 626 | All configurations have the following mappings between TOML keys and environment variables: 627 | 628 | - All environment variables start with `AILINGBOT_`. 629 | - Double underscores `__` are used as separators between levels. 630 | - Underscores in configuration keys are preserved in environment variables. 631 | - Case-insensitive. 632 | 633 | For example: 634 | 635 | - The corresponding environment variable of `some_conf` is `AILINGBOT_SOME_CONF`. 636 | - The corresponding environment variable of `some_conf.conf_1` is `AILINGBOT_SOME_CONF__CONF_1`. 637 | - The corresponding environment variable of `some_conf.conf_1.subconf` is `AILINGBOT_SOME_CONF__CONF_1__SUBCONF`. 638 | 639 | ### Configuration Items 640 | 641 | #### General 642 | 643 | | Configuration Item | Description | TOML | Environment Variable | 644 | |--------------------|-----------------------------------------------------------------------------------------------------------------------------------------------|----------------------|---------------------------------| 645 | | Language | Language code (Reference: http://www.lingoes.net/en/translator/langcode.htm) | lang | AILINGBOT_LANG | 646 | | Timezone | Timezone code (Reference: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) | tz | AILINGBOT_TZ | 647 | | Policy Name | Predefined policy name or complete policy class path | policy.name | AILINGBOT_POLICY__NAME | 648 | | Channel Name | Predefined channel name | channel.name | AILINGBOT_CHANNEL__NAME | 649 | | Webhook Path | Complete class path of non-predefined channel webhook | channel.webhook_name | AILINGBOT_CHANNEL__WEBHOOK_NAME | 650 | | Agent Path | Complete class path of non-predefined channel agent | channel.agent_name | AILINGBOT_CHANNEL__AGENT_NAME | 651 | | Uvicorn Config | All uvicorn configurations (Reference: [uvicorn settings](https://www.uvicorn.org/settings/)). These configurations will be passed to uvicorn | uvicorn.* | AILINGBOT_UVICORN__* | 652 | 653 | Configuration example: 654 | 655 | ```toml 656 | lang = "zh_CN" 657 | tz = "Asia/Shanghai" 658 | 659 | [policy] 660 | name = "conversation" 661 | # More policy configurations 662 | 663 | [channel] 664 | name = "wechatwork" 665 | # More channel configurations 666 | 667 | [uvicorn] 668 | host = "0.0.0.0" 669 | port = 8080 670 | ``` 671 | 672 | #### Built-in Policy Configuration 673 | 674 | ##### conversation 675 | 676 | Conversation uses LangChain's Conversation as the policy, which enables direct interaction with LLM and has a 677 | conversation history context, enabling multi-turn conversations. 678 | 679 | | Configuration Item | Description | TOML | Environment Variable | 680 | |--------------------|---------------------------------------------------------------|---------------------|--------------------------------| 681 | | History Size | Indicates how many rounds of historical conversations to keep | policy.history_size | AILINGBOT_POLICY__HISTORY_SIZE | 682 | 683 | Configuration example: 684 | 685 | ```toml 686 | # Use the conversation policy and keep 5 rounds of historical conversations 687 | [policy] 688 | name = "conversation" 689 | history_size = 5 690 | ``` 691 | 692 | ##### document_qa 693 | 694 | Document_qa uses LangChain's [Stuff](https://python.langchain.com/docs/modules/chains/document/stuff) as the policy. 695 | Users can upload a document and then ask questions based on the document content. 696 | 697 | | Configuration Item | Description | TOML | Environment Variable | 698 | |--------------------|---------------------------------------------------|----------------------|---------------------------------| 699 | | Chunk Size | Corresponds to LangChain Splitter's chunk_size | policy.chunk_size | AILINGBOT_POLICY__CHUNK_SIZE | 700 | | Chunk Overlap | Corresponds to LangChain Splitter's chunk_overlap | policy.chunk_overlap | AILINGBOT_POLICY__CHUNK_OVERLAP | 701 | 702 | Configuration example: 703 | 704 | ```toml 705 | # Use the document_qa policy, with chunk_size and chunk_overlap set to 1000 and 0, respectively 706 | [policy] 707 | name = "document_qa" 708 | chunk_size = 1000 709 | chunk_overlap = 0 710 | ``` 711 | 712 | #### Model Configuration 713 | 714 | The model configuration is consistent with LangChain. The following is an example. 715 | 716 | ##### OpenAI 717 | 718 | ```toml 719 | [policy.llm] 720 | _type = "openai" # Corresponding environment variable: AILINGBOT_POLICY__LLM___TYPE 721 | model_name = "gpt-3.5-turbo" # Corresponding environment variable: AILINGBOT_POLICY__LLM__MODEL_NAME 722 | openai_api_key = "sk-pd*****************************aAb" # Corresponding environment variable: AILINGBOT_POLICY__LLM__OPENAI_API_KEY 723 | ``` 724 | 725 | ## Command Line Tools 726 | 727 | ### Initialize Configuration File (init) 728 | 729 | #### Usage 730 | 731 | The `init` command generates a configuration file `settings.toml` in the current directory. By default, the user will be 732 | prompted interactively. You can use the `--silence` option to generate the configuration file directly using default 733 | settings. 734 | 735 | ```text 736 | Usage: ailingbot init [OPTIONS] 737 | 738 | Initialize the AilingBot environment. 739 | 740 | Options: 741 | --silence Without asking the user. 742 | --overwrite Overwrite existing file if a file with the same name already 743 | exists. 744 | --help Show this message and exit. 745 | ``` 746 | 747 | #### Options 748 | 749 | | Option | Description | Type | Remarks | 750 | |-------------|----------------------------------------------------------------------|------|---------| 751 | | --silence | Generate the default configuration directly without asking the user. | Flag | | 752 | | --overwrite | Allow overwriting the `settings.toml` file in the current directory. | Flag | | 753 | 754 | ### View Current Configuration (config) 755 | 756 | The `config` command reads the current environment configuration (including the configuration file and environment 757 | variables) and merges them. 758 | 759 | #### Usage 760 | 761 | ```text 762 | Usage: ailingbot config [OPTIONS] 763 | 764 | Show current configuration information. 765 | 766 | Options: 767 | -k, --config-key TEXT Configuration key. 768 | --help Show this message and exit. 769 | ``` 770 | 771 | #### Options 772 | 773 | | Option | Description | Type | Remarks | 774 | |------------------|-------------------|--------|--------------------------------------------------------------------------| 775 | | -k, --config-key | Configuration key | String | If not passed, the complete configuration information will be displayed. | 776 | 777 | ### Start Command Line Bot (chat) 778 | 779 | The `chat` command starts an interactive command-line bot for testing the current chat policy. 780 | 781 | #### Usage 782 | 783 | ```text 784 | Usage: ailingbot chat [OPTIONS] 785 | 786 | Start an interactive bot conversation environment. 787 | 788 | Options: 789 | --debug Enable debug mode. 790 | --help Show this message and exit. 791 | ``` 792 | 793 | #### Options 794 | 795 | | Option | Description | Type | Remarks | 796 | |---------|-------------------|------|------------------------------------------------------------------| 797 | | --debug | Enable debug mode | Flag | The debug mode will output more information, such as the prompt. | 798 | 799 | ### Start Webhook Service (serve) 800 | 801 | The `serve` command starts a Webhook HTTP server for interacting with specific IM. 802 | 803 | #### Usage 804 | 805 | ```text 806 | Usage: ailingbot serve [OPTIONS] 807 | 808 | Run webhook server to receive events. 809 | 810 | Options: 811 | --log-level [TRACE|DEBUG|INFO|SUCCESS|WARNING|ERROR|CRITICAL] 812 | The minimum severity level from which logged 813 | messages should be sent to(read from 814 | environment variable AILINGBOT_LOG_LEVEL if 815 | is not passed into). [default: TRACE] 816 | --log-file TEXT STDOUT, STDERR, or file path(read from 817 | environment variable AILINGBOT_LOG_FILE if 818 | is not passed into). [default: STDERR] 819 | --help Show this message and exit. 820 | ``` 821 | 822 | #### Options 823 | 824 | | Option | Description | Type | Remarks | 825 | |-------------|--------------------------------------------------------------------------|--------|-------------------------------------------------------------| 826 | | --log-level | The minimum severity level from which logged messages should be sent to. | String | By default, all log levels will be displayed (TRACE). | 827 | | --log-file | The location where logs are output. | String | By default, logs will be output to standard error (STDERR). | 828 | 829 | ### Start API Service (api) 830 | 831 | The `api` command starts the API HTTP server. 832 | 833 | #### Usage 834 | 835 | ```text 836 | Usage: ailingbot api [OPTIONS] 837 | 838 | Run endpoint server. 839 | 840 | Options: 841 | --log-level [TRACE|DEBUG|INFO|SUCCESS|WARNING|ERROR|CRITICAL] 842 | The minimum severity level from which logged 843 | messages should be sent to(read from 844 | environment variable AILINGBOT_LOG_LEVEL if 845 | is not passed into). [default: TRACE] 846 | --log-file TEXT STDOUT, STDERR, or file path(read from 847 | environment variable AILINGBOT_LOG_FILE if 848 | is not passed into). [default: STDERR] 849 | --help Show this message and exit. 850 | ``` 851 | 852 | #### Options 853 | 854 | | Option | Description | Type | Remarks | 855 | |-------------|--------------------------------------------------------------------|--------|---------------------------------------------------------| 856 | | --log-level | Display log level, which will display logs at this level and above | String | By default, all levels are displayed (TRACE) | 857 | | --log-file | Log output location | String | By default, logs are printed to standard error (STDERR) | 858 | 859 | ## 🔌API 860 | 861 | TBD 862 | 863 | # 💻Development Guide 864 | 865 | ## Development Guidelines 866 | 867 | TBD 868 | 869 | ## Developing Chat Policy 870 | 871 | TBD 872 | 873 | ## Developing Channel 874 | 875 | TBD 876 | 877 | # 🤔Frequently Asked Questions 878 | 879 | - Due to the fact that WeChat Work does not support uploading file event callbacks, the built-in `document_qa` 880 | policy cannot be used for WeChat Work. 881 | - The webhook of each IM requires a public IP. If you do not have one, you can consider testing locally through the " 882 | intranet penetration" solution. Please refer to online resources for specific methods. 883 | - We expect the chat policy to be stateless, and the state should be stored externally. However, in specific 884 | implementations, the policy may still have local states (such as storing conversation history in local variables). 885 | Therefore, when uvicorn has multiple worker processes, these local states cannot be shared because each process has a 886 | separate chat policy instance, and a request from the same user may be responded to by different workers, leading to 887 | unexpected behavior. To avoid this, please ensure that at least one of the following two conditions is met: 888 | - Chat policy does not use local states. 889 | - Only one uvicorn worker is started. 890 | 891 | # 🎯Roadmap 892 | 893 | - [ ] Provide complete usage and developer documentation. 894 | - [ ] Support more channels. 895 | - [x] WeChat Work 896 | - [x] Feishu 897 | - [x] DingTalk 898 | - [x] Slack 899 | - [ ] Support more request message types. 900 | - [x] Text request 901 | - [ ] Image request 902 | - [x] File request 903 | - [ ] Support more response message types. 904 | - [x] Text response 905 | - [ ] Image response 906 | - [ ] File response 907 | - [ ] Markdown response 908 | - [ ] Table response 909 | - [ ] Develop more out-of-the-box chat policies. 910 | - [x] Multi-round conversation policy 911 | - [x] Document question and answer policy 912 | - [ ] Database question and answer policy 913 | - [ ] Online search question and answer policy 914 | - [ ] Support calling standalone chat policy services through HTTP. 915 | - [ ] Abstract basic components 916 | - [ ] Large language model 917 | - [ ] Knowledge base 918 | - [ ] Tools 919 | - [ ] Support local model deployment. 920 | - [ ] ChatGLM-6B 921 | - [x] Support API. 922 | - [ ] Web management dashboard. 923 | - [x] Provide deployment capability based on Docker containers. 924 | - [ ] Enhance the observability and controllability of the system. 925 | - [ ] Complete test cases. 926 | -------------------------------------------------------------------------------- /README_zh_CN.md: -------------------------------------------------------------------------------- 1 | 🇬🇧[English](https://github.com/ericzhang-cn/ailingbot/blob/main/README.md) 2 | 🇨🇳[简体中文](https://github.com/ericzhang-cn/ailingbot/blob/main/README_zh_CN.md) 3 | 4 | --- 5 | 6 | ![Python package workflow](https://github.com/ericzhang-cn/ailingbot/actions/workflows/python-package.yml/badge.svg) 7 | ![Pylint workflow](https://github.com/ericzhang-cn/ailingbot/actions/workflows/pylint.yml/badge.svg) 8 | ![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg) 9 | 10 |

11 | AilingBot 12 |

13 | 14 |

AilingBot - 一站式解决方案,为你的IM机器人接入AI强大能力。

15 | 16 | # 目录 17 | 18 | * [AilingBot是什么](#ailingbot是什么) 19 | * [特点](#特点) 20 | * [🚀快速开始](#快速开始) 21 | * [5分钟启动一个AI聊天机器人](#5分钟启动一个ai聊天机器人) 22 | * [通过Docker](#通过docker) 23 | * [通过PIP](#通过pip) 24 | * [安装](#安装) 25 | * [生成配置文件](#生成配置文件) 26 | * [启动机器人](#启动机器人) 27 | * [启动API服务](#启动api服务) 28 | * [通过Docker](#通过docker-1) 29 | * [通过PIP](#通过pip-1) 30 | * [安装](#安装-1) 31 | * [生成配置文件](#生成配置文件-1) 32 | * [启动服务](#启动服务) 33 | * [接入企业微信](#接入企业微信) 34 | * [通过Docker](#通过docker-2) 35 | * [通过PIP](#通过pip-2) 36 | * [安装](#安装-2) 37 | * [生成配置文件](#生成配置文件-2) 38 | * [修改配置文件](#修改配置文件) 39 | * [启动服务](#启动服务-1) 40 | * [接入飞书](#接入飞书) 41 | * [通过Docker](#通过docker-3) 42 | * [通过PIP](#通过pip-3) 43 | * [安装](#安装-3) 44 | * [生成配置文件](#生成配置文件-3) 45 | * [修改配置文件](#修改配置文件-1) 46 | * [启动服务](#启动服务-2) 47 | * [接入钉钉](#接入钉钉) 48 | * [通过Docker](#通过docker-4) 49 | * [通过PIP](#通过pip-4) 50 | * [安装](#安装-4) 51 | * [生成配置文件](#生成配置文件-4) 52 | * [修改配置文件](#修改配置文件-2) 53 | * [启动服务](#启动服务-3) 54 | * [接入Slack](#接入slack) 55 | * [通过Docker](#通过docker-5) 56 | * [通过PIP](#通过pip-5) 57 | * [安装](#安装-5) 58 | * [生成配置文件](#生成配置文件-5) 59 | * [修改配置文件](#修改配置文件-3) 60 | * [启动服务](#启动服务-4) 61 | * [📖使用指南](#使用指南) 62 | * [主要流程](#主要流程) 63 | * [主要概念](#主要概念) 64 | * [配置](#配置) 65 | * [配置方式](#配置方式) 66 | * [配置映射关系](#配置映射关系) 67 | * [配置项](#配置项) 68 | * [通用](#通用) 69 | * [内置会话策略配置](#内置会话策略配置) 70 | * [conversation](#conversation) 71 | * [document_qa](#document_qa) 72 | * [模型配置](#模型配置) 73 | * [OpenAI](#openai) 74 | * [内置Channel配置](#内置channel配置) 75 | * [企业微信](#企业微信) 76 | * [飞书](#飞书) 77 | * [钉钉](#钉钉) 78 | * [Slack](#slack) 79 | * [命令行工具](#命令行工具) 80 | * [初始化配置文件(init)](#初始化配置文件init) 81 | * [使用方法](#使用方法) 82 | * [Options](#options) 83 | * [查看当前配置(config)](#查看当前配置config) 84 | * [使用方法](#使用方法-1) 85 | * [Options](#options-1) 86 | * [启动命令行机器人(chat)](#启动命令行机器人chat) 87 | * [使用方法](#使用方法-2) 88 | * [Options](#options-2) 89 | * [启动Webhook服务(serve)](#启动webhook服务serve) 90 | * [使用方法](#使用方法-3) 91 | * [Options](#options-3) 92 | * [启动API服务(api)](#启动api服务api) 93 | * [使用方法](#使用方法-4) 94 | * [Options](#options-4) 95 | * [🔌API](#api) 96 | * [💻开发指南](#开发指南) 97 | * [开发总则](#开发总则) 98 | * [开发对话策略](#开发对话策略) 99 | * [开发Channel](#开发channel) 100 | * [🤔常见问题](#常见问题) 101 | * [🎯发展计划](#发展计划) 102 | 103 | # AilingBot是什么 104 | 105 | AilingBot是一个开源的工程开发框架,同时也是IM机器人接入AI模型的一站式解决方案。通过AilingBot你可以: 106 | 107 | - ☕**零代码使用**:快速将现有AI大模型能力接入主流IM机器人(如企业微信、飞书、钉钉、Slack等),实现通过IM机器人与AI大模型交互以完成业务需求。目前内置了多轮对话和文档知识问答两种能力,未来将内置更多能力 108 | - 🛠️**二次开发**:AilingBot提供了一套清晰的工程架构、接口定义和必需基础组件,无需从头开始重复开发大模型服务的工程框架,只需实现自己Chat 109 | Policy,并通过一些简单的配置,就能完成端到端的AI模型对IM机器人的赋能。同时也支持通过开发自己的Channel扩展到你自己的端(如自己的IM、Web应用或移动端应用) 110 | 111 | # 特点 112 | 113 | - 💯**开源&免费**:完全开源且免费 114 | - 📦**开箱即用**:无需开发,预置接入现有主流IM及AI模型的能力 115 | - 🔗**LangChain友好**:方便集成LangChain 116 | - 🧩**模块化**:项目采用模块化组织,模块之间通过抽象协议依赖,同类模块实现协议即可即插即用 117 | - 💻**可扩展**:可以扩展AilingBot的使用场景和能力。例如接入到新的IM,新的AI模型,或者定制自己的对话策略 118 | - 🔥**高性能**:AilingBot采用基于协程的异步模式,提高系统的高并发性能。同时可以通过多进程进一步提升系统的高并发处理能力 119 | - 🔌**通过API集成**:AilingBot提供一组清晰的API接口,方便与其他系统及流程集成协同 120 | 121 | # 🚀快速开始 122 | 123 | ## 5分钟启动一个AI聊天机器人 124 | 125 | 下面将看到如何通过AilingBot快速启动一个基于命令行界面的AI机器人,效果如图: 126 |

127 | 命令行机器人 128 |

129 | 130 | 131 | > 💡首先你需要有一个OpenAI API key。如果没有参考互联网上相关资料获取 132 | 133 | ### 通过Docker 134 | 135 | ```shell 136 | git clone https://github.com/ericzhang-cn/ailingbot.git ailingbot 137 | cd ailingbot 138 | docker build -t ailingbot . 139 | docker run -it --rm \ 140 | -e AILINGBOT_POLICY__LLM__OPENAI_API_KEY={你的OpenAI API key} \ 141 | ailingbot poetry run ailingbot chat 142 | ``` 143 | 144 | ### 通过PIP 145 | 146 | #### 安装 147 | 148 | ```shell 149 | pip install ailingbot 150 | ``` 151 | 152 | #### 生成配置文件 153 | 154 | ```shell 155 | ailingbot init --silence --overwrite 156 | ``` 157 | 158 | 此时在当前目录会创建一个叫settings.toml的文件,这个文件就是AilingBot的配置文件。 159 | 接下来修改必要配置,启动机器人只需一项配置,找到settings.toml中以下部分: 160 | 161 | ```toml 162 | [policy.llm] 163 | _type = "openai" 164 | model_name = "gpt-3.5-turbo" 165 | openai_api_key = "" 166 | temperature = 0 167 | ``` 168 | 169 | 将其中`openai_api_key`的值改为你的真实OpenAI API key。 170 | 171 | #### 启动机器人 172 | 173 | 通过如下命令启动机器人: 174 | 175 | ```shell 176 | ailingbot chat 177 | ``` 178 | 179 | ## 启动API服务 180 | 181 | ### 通过Docker 182 | 183 | ```shell 184 | git clone https://github.com/ericzhang-cn/ailingbot.git ailingbot 185 | cd ailingbot 186 | docker build -t ailingbot . 187 | docker run -it --rm \ 188 | -e AILINGBOT_POLICY__LLM__OPENAI_API_KEY={你的OpenAI API key} \ 189 | -p 8080:8080 \ 190 | ailingbot poetry run ailingbot api 191 | ``` 192 | 193 | ### 通过PIP 194 | 195 | #### 安装 196 | 197 | ```shell 198 | pip install ailingbot 199 | ``` 200 | 201 | #### 生成配置文件 202 | 203 | 与启动命令行机器人做法相同。 204 | 205 | #### 启动服务 206 | 207 | 通过如下命令启动机器人: 208 | 209 | ```shell 210 | ailingbot api 211 | ``` 212 | 213 | 此时在浏览器输入 `http://localshot:8080/docs` 即可看到API文档。(如是非本地启动,请输入 `http://{你的公网IP}:8080/docs`) 214 | 215 |

216 | Swagger API文档 217 |

218 | 219 | 请求示例如下: 220 | 221 | ```shell 222 | curl -X 'POST' \ 223 | 'http://localhost:8080/chat/' \ 224 | -H 'accept: application/json' \ 225 | -H 'Content-Type: application/json' \ 226 | -d '{ 227 | "text": "你好" 228 | }' 229 | ``` 230 | 231 | 得到响应: 232 | 233 | ```json 234 | { 235 | "type": "text", 236 | "conversation_id": "default_conversation", 237 | "uuid": "afb35218-2978-404a-ab39-72a9db6f303b", 238 | "ack_uuid": "3f09933c-e577-49a5-8f56-fa328daa136f", 239 | "receiver_id": "anonymous", 240 | "scope": "user", 241 | "meta": {}, 242 | "echo": {}, 243 | "text": "你好!很高兴和你聊天。有什么我可以帮助你的吗?", 244 | "reason": null, 245 | "suggestion": null 246 | } 247 | ``` 248 | 249 | ## 接入企业微信 250 | 251 | 下面演示如何快速将上面的机器人接入企业微信。 252 | 253 | ### 通过Docker 254 | 255 | ```shell 256 | git clone https://github.com/ericzhang-cn/ailingbot.git ailingbot 257 | cd ailingbot 258 | docker build -t ailingbot . 259 | docker run -d \ 260 | -e AILINGBOT_POLICY__NAME=conversation \ 261 | -e AILINGBOT_POLICY__HISTORY_SIZE=5 \ 262 | -e AILINGBOT_POLICY__LLM__OPENAI_API_KEY={你的OpenAI API key} \ 263 | -e AILINGBOT_CHANNEL__NAME=wechatwork \ 264 | -e AILINGBOT_CHANNEL__CORPID={你的企业微信机器人corpid} \ 265 | -e AILINGBOT_CHANNEL__CORPSECRET={你的企业微信机器人corpsecret} \ 266 | -e AILINGBOT_CHANNEL__AGENTID={你的企业微信机器人agentid} \ 267 | -e AILINGBOT_CHANNEL__TOKEN={你的企业微信机器人webhook token} \ 268 | -e AILINGBOT_CHANNEL__AES_KEY={你的企业微信机器人webhook aes_key} \ 269 | -p 8080:8080 \ 270 | ailingbot poetry run ailingbot serve 271 | ``` 272 | 273 | ### 通过PIP 274 | 275 | #### 安装 276 | 277 | ```shell 278 | pip install ailingbot 279 | ``` 280 | 281 | #### 生成配置文件 282 | 283 | ```shell 284 | ailingbot init --silence --overwrite 285 | ``` 286 | 287 | #### 修改配置文件 288 | 289 | 打开`settings.toml`,将其中的下面部分填入你的企业微信应用真实信息: 290 | 291 | ```toml 292 | [channel] 293 | name = "wechatwork" 294 | corpid = "" # 填写真实信息 295 | corpsecret = "" # 填写真实信息 296 | agentid = 0 # 填写真实信息 297 | token = "" # 填写真实信息 298 | aes_key = "" # 填写真实信息 299 | ``` 300 | 301 | 在llm中填入你的OpenAI API Key: 302 | 303 | ```toml 304 | [policy.llm] 305 | _type = "openai" 306 | model_name = "gpt-3.5-turbo" 307 | openai_api_key = "" # 这里填入真实OpenAI API Key 308 | temperature = 0 309 | ``` 310 | 311 | #### 启动服务 312 | 313 | ```shell 314 | ailingbot serve 315 | ``` 316 | 317 | 最后我们需要去企业微信的管理后台,将webhook地址配置好,以便企业微信知道将接收到的用户消息转发到我们的webhook。 318 | Webhook的URL为:`http(s)://你的公网IP:8080/webhook/wechatwork/event/` 319 | 320 | 完成以上配置后,就可以在企业微信中找到机器人,进行对话了: 321 | 322 |

323 | 企业微信机器人 324 |

325 | 326 | ## 接入飞书 327 | 328 | 下面演示如何快速将上面的机器人接入飞书,并启用一个新的对话策略:上传文档并针对文档进行知识问答。 329 | 330 | ### 通过Docker 331 | 332 | ```shell 333 | git clone https://github.com/ericzhang-cn/ailingbot.git ailingbot 334 | cd ailingbot 335 | docker build -t ailingbot . 336 | docker run -d \ 337 | -e AILINGBOT_POLICY__NAME=document_qa \ 338 | -e AILINGBOT_POLICY__CHUNK_SIZE=1000 \ 339 | -e AILINGBOT_POLICY__CHUNK_OVERLAP=0 \ 340 | -e AILINGBOT_POLICY__LLM__OPENAI_API_KEY={你的OpenAI API key} \ 341 | -e AILINGBOT_POLICY__LLM__MODEL_NAME=gpt-3.5-turbo-16k \ 342 | -e AILINGBOT_CHANNEL__NAME=feishu \ 343 | -e AILINGBOT_CHANNEL__APP_ID={你的飞书机器人app id} \ 344 | -e AILINGBOT_CHANNEL__APP_SECRET={你的飞书机器人app secret} \ 345 | -e AILINGBOT_CHANNEL__VERIFICATION_TOKEN={你的飞书机器人webhook verification token} \ 346 | -p 8080:8080 \ 347 | ailingbot poetry run ailingbot serve 348 | ``` 349 | 350 | ### 通过PIP 351 | 352 | #### 安装 353 | 354 | ```shell 355 | pip install ailingbot 356 | ``` 357 | 358 | #### 生成配置文件 359 | 360 | ```shell 361 | ailingbot init --silence --overwrite 362 | ``` 363 | 364 | #### 修改配置文件 365 | 366 | 打开`settings.toml`,将其中的channel部分改为如下,并填入你的飞书真实信息: 367 | 368 | ```toml 369 | [channel] 370 | name = "feishu" 371 | app_id = "" # 填写真实信息 372 | app_secret = "" # 填写真实信息 373 | verification_token = "" # 填写真实信息 374 | ``` 375 | 376 | 将policy部分替换为文档问答策略: 377 | 378 | ```toml 379 | [policy] 380 | name = "document_qa" 381 | chunk_size = 1000 382 | chunk_overlap = 5 383 | ``` 384 | 385 | 最后建议在使用文档问答策略时,使用16k模型,因此将`policy.llm.model_name`修改为如下配置: 386 | 387 | ```toml 388 | [policy.llm] 389 | _type = "openai" 390 | model_name = "gpt-3.5-turbo-16k" # 这里改为gpt-3.5-turbo-16k 391 | openai_api_key = "" # 填写真实信息 392 | temperature = 0 393 | ``` 394 | 395 | #### 启动服务 396 | 397 | ```shell 398 | ailingbot serve 399 | ``` 400 | 401 | 最后我们需要去飞书的管理后台,将webhook地址配置好。 402 | 飞书Webhook的URL为:`http(s)://你的公网IP:8080/webhook/feishu/event/` 403 | 404 | 完成以上配置后,就可以在飞书中找到机器人,进行对话了: 405 | 406 |

407 | 飞书机器人 408 |

409 | 410 | ## 接入钉钉 411 | 412 | 下面演示如何快速将上面的机器人接入钉钉。 413 | 414 | ### 通过Docker 415 | 416 | ```shell 417 | git clone https://github.com/ericzhang-cn/ailingbot.git ailingbot 418 | cd ailingbot 419 | docker build -t ailingbot . 420 | docker run -d \ 421 | -e AILINGBOT_POLICY__NAME=conversation \ 422 | -e AILINGBOT_POLICY__HISTORY_SIZE=5 \ 423 | -e AILINGBOT_POLICY__LLM__OPENAI_API_KEY={你的OpenAI API key} \ 424 | -e AILINGBOT_CHANNEL__NAME=dingtalk \ 425 | -e AILINGBOT_CHANNEL__APP_KEY={你的钉钉机器人app key} \ 426 | -e AILINGBOT_CHANNEL__APP_SECRET={你的钉钉机器人app secret} \ 427 | -e AILINGBOT_CHANNEL__ROBOT_CODE={你的钉钉机器人robot code} \ 428 | -p 8080:8080 \ 429 | ailingbot poetry run ailingbot serve 430 | ``` 431 | 432 | ### 通过PIP 433 | 434 | #### 安装 435 | 436 | ```shell 437 | pip install ailingbot 438 | ``` 439 | 440 | #### 生成配置文件 441 | 442 | ```shell 443 | ailingbot init --silence --overwrite 444 | ``` 445 | 446 | #### 修改配置文件 447 | 448 | 打开`settings.toml`,将其中的channel部分改为如下,并填入你的飞书真实信息: 449 | 450 | ```toml 451 | [channel] 452 | name = "dingtalk" 453 | app_key = "" # 填写真实信息 454 | app_secret = "" # 填写真实信息 455 | robot_code = "" # 填写真实信息 456 | ``` 457 | 458 | #### 启动服务 459 | 460 | ```shell 461 | ailingbot serve 462 | ``` 463 | 464 | 最后我们需要去钉钉的管理后台,将webhook地址配置好。 465 | 钉钉Webhook的URL为:`http(s)://你的公网IP:8080/webhook/dingtalk/event/` 466 | 467 | 完成以上配置后,就可以在钉钉中找到机器人,进行对话了: 468 | 469 |

470 | 钉钉机器人 471 |

472 | 473 | ## 接入Slack 474 | 475 | 下面演示如何快速将上面的机器人接入Slack,并启用文档知识问答策略。 476 | 477 | ### 通过Docker 478 | 479 | ```shell 480 | git clone https://github.com/ericzhang-cn/ailingbot.git ailingbot 481 | cd ailingbot 482 | docker build -t ailingbot . 483 | docker run -d \ 484 | -e AILINGBOT_POLICY__NAME=document_qa \ 485 | -e AILINGBOT_POLICY__CHUNK_SIZE=1000 \ 486 | -e AILINGBOT_POLICY__CHUNK_OVERLAP=0 \ 487 | -e AILINGBOT_POLICY__LLM__OPENAI_API_KEY={你的OpenAI API key} \ 488 | -e AILINGBOT_POLICY__LLM__MODEL_NAME=gpt-3.5-turbo-16k \ 489 | -e AILINGBOT_CHANNEL__NAME=slack \ 490 | -e AILINGBOT_CHANNEL__VERIFICATION_TOKEN={你的Slack App webhook verification token} \ 491 | -e AILINGBOT_CHANNEL__OAUTH_TOKEN={你的Slack App oauth token} \ 492 | -p 8080:8080 \ 493 | ailingbot poetry run ailingbot serve 494 | ``` 495 | 496 | ### 通过PIP 497 | 498 | #### 安装 499 | 500 | ```shell 501 | pip install ailingbot 502 | ``` 503 | 504 | #### 生成配置文件 505 | 506 | ```shell 507 | ailingbot init --silence --overwrite 508 | ``` 509 | 510 | #### 修改配置文件 511 | 512 | 打开`settings.toml`,将其中的channel部分改为如下,并填入你的飞书真实信息: 513 | 514 | ```toml 515 | [channel] 516 | name = "slack" 517 | verification_token = "" # 填写真实信息 518 | oauth_token = "" # 填写真实信息 519 | ``` 520 | 521 | 将policy部分替换为文档问答策略: 522 | 523 | ```toml 524 | [policy] 525 | name = "document_qa" 526 | chunk_size = 1000 527 | chunk_overlap = 5 528 | ``` 529 | 530 | 最后建议在使用文档问答策略时,使用16k模型,因此将`policy.llm.model_name`修改为如下配置: 531 | 532 | ```toml 533 | [policy.llm] 534 | _type = "openai" 535 | model_name = "gpt-3.5-turbo-16k" # 这里改为gpt-3.5-turbo-16k 536 | openai_api_key = "" # 填写真实信息 537 | temperature = 0 538 | ``` 539 | 540 | #### 启动服务 541 | 542 | ```shell 543 | ailingbot serve 544 | ``` 545 | 546 | 最后我们需要去Slack的管理后台,将webhook地址配置好。 547 | 飞书Webhook的URL为:`http(s)://你的公网IP:8080/webhook/slack/event/` 548 | 549 | 完成以上配置后,就可以在Slack中找到机器人,进行对话了: 550 | 551 |

552 | Slack机器人 553 |

554 | 555 | # 📖使用指南 556 | 557 | ## 主要流程 558 | 559 | AilingBot的主要处理流程如下图: 560 | 561 |

562 | 主要流程 563 |

564 | 565 | 1. 首先用户将消息发送给IM的机器人 566 | 2. 如果配置了webhook,即时通讯工具会将发送给机器人的请求转发到webhook服务地址 567 | 3. Webhook服务将IM原始消息经过处理,转为AilingBot内部的消息格式,发送给ChatBot 568 | 4. ChatBot会根据所配置的会话策略(Chat Policy),处理请求并形成响应消息。这个过程中,ChatBot 569 | 可能会进行请求大语言模型、访问向量数据库、调用外部API等操作以完成请求处理 570 | 5. ChatBot将响应信息发送给IM Agent,IM Agent负责将AilingBot内部响应信息格式转换成 571 | 特定IM的格式,并调用IM开放能力API发送响应消息 572 | 6. IM机器人将消息显示给用户,完成整个处理过程 573 | 574 | ## 主要概念 575 | 576 | - **IM机器人**:多数即时通讯工具内置的能力,允许管理员创建一个机器人,并通过程序处理用户的消息 577 | - **Channel**:Channel表示不同终端,可以是一个IM,也可能是一个自定义终端(如Web) 578 | - **Webhook**:一个http(s)服务,用于接收IM机器人转发的用户消息,不同Channel对于webhook有自己的规范,因此需要有自己的webhook实现 579 | - **IM Agent**:用于调用IM开放能力API,不同的IM开放能力API不同,因此每个Channel需要有对应Agent实现 580 | - **ChatBot**:用于接收和响应用户消息的核心组件 581 | - **会话策略**:具体定义如何响应用户,被ChatBot调用。一个会话策略具体定义了机器人的能力,如闲聊、进行知识问答等 582 | - **LLM**:大语言模型,如何OpenAI的ChatGPT,开放的ChatGLM等均属于不同的大语言模型,大语言模型是实现AI能力的关键组件 583 | 584 | ## 配置 585 | 586 | ### 配置方式 587 | 588 | AilingBot的配置可以通过两种方式: 589 | 590 | - **通过配置文件**:AilingBot读取当前目录的`settings.toml`作为配置文件,其文件格式为[TOML](https://toml.io/en/) 591 | 具体配置项见下文 592 | - **通过环境变量**:AilingBot也会读取环境变量中配置项,具体环境变量列表见下文 593 | 594 | > 💡配置文件和环境变量可以混合使用,当一个配置项同时存在于两者时,优先使用环境变量 595 | 596 | ### 配置映射关系 597 | 598 | 所有配置,TOML配置键和环境变量有如下映射关系: 599 | 600 | - 所有环境变量以`AILINGBOT_`开头 601 | - 层级之间用两个下划线`__`隔开 602 | - 配置键内部的下划线在环境变量中保留 603 | - 不区分大小写 604 | 605 | 例如: 606 | 607 | - `some_conf`的对应环境变量为`AILINGBOT_SOME_CONF` 608 | - `some_conf.conf_1`的对应环境变量为`AILINGBOT_SOME_CONF__CONF_1` 609 | - `some_conf.conf_1.subconf`的对应环境变量为`AILINGBOT_SOME_CONF__CONF_1__SUBCONF` 610 | 611 | ### 配置项 612 | 613 | #### 通用 614 | 615 | | 配置项 | 说明 | TOML | 环境变量 | 616 | |-----------|----------------------------------------------------------------------------------------|----------------------|---------------------------------| 617 | | 语言 | 语言码(参考:http://www.lingoes.net/en/translator/langcode.htm) | lang | AILINGBOT_LANG | 618 | | 时区 | 时区码(参考:https://en.wikipedia.org/wiki/List_of_tz_database_time_zones | tz | AILINGBOT_TZ | 619 | | 会话策略名称 | 预置会话策略名称或完整会话策略class路径 | policy.name | AILINGBOT_POLICY__NAME | 620 | | Channel名称 | 预置Channel名称 | channel.name | AILINGBOT_CHANNEL__NAME | 621 | | Webhook路径 | 非预置Channel webhook的完整class路径 | channel.webhook_name | AILINGBOT_CHANNEL__WEBHOOK_NAME | 622 | | Agent路径 | 非预置Channel agent的完整class路径 | channel.agent_name | AILINGBOT_CHANNEL__AGENT_NAME | 623 | | Uvicorn配置 | 所有uvicorn配置(参考:[uvicorn settings](https://www.uvicorn.org/settings/)),这部分配置会透传给uvicorn | uvicorn.* | AILINGBOT_UVICORN__* | 624 | 625 | 配置示例: 626 | 627 | ```toml 628 | lang = "zh_CN" 629 | tz = "Asia/Shanghai" 630 | 631 | [policy] 632 | name = "conversation" 633 | # 更多policy配置 634 | 635 | [policy.llm] 636 | # 模型配置 637 | 638 | [channel] 639 | name = "wechatwork" 640 | # 更多channel配置 641 | 642 | [uvicorn] 643 | host = "0.0.0.0" 644 | port = 8080 645 | ``` 646 | 647 | #### 内置会话策略配置 648 | 649 | ##### conversation 650 | 651 | conversation使用LangChain的Conversation作为会话策略,其效果为直接和LLM对话,且带有对话历史上下文,因此可以进行多轮会话。 652 | 653 | | 配置项 | 说明 | TOML | 环境变量 | 654 | |--------|-------------|---------------------|--------------------------------| 655 | | 会话历史长度 | 表示保留多少轮历史会话 | policy.history_size | AILINGBOT_POLICY__HISTORY_SIZE | 656 | 657 | 配置示例: 658 | 659 | ```toml 660 | # 使用conversation策略,保留5轮历史会话 661 | [policy] 662 | name = "conversation" 663 | history_size = 5 664 | ``` 665 | 666 | ##### document_qa 667 | 668 | document_qa使用LangChain的[Stuff](https://python.langchain.com/docs/modules/chains/document/stuff)作为对话策略。 669 | 用户可上传一个文档,然后针对文档内容进行提问。 670 | 671 | | 配置项 | 说明 | TOML | 环境变量 | 672 | |---------|------------------------------------|----------------------|---------------------------------| 673 | | 文档切分块大小 | 对应LangChain Splitter的chunk_size | policy.chunk_size | AILINGBOT_POLICY__CHUNK_SIZE | 674 | | 文档切重叠 | 对应LangChain Splitter的chunk_overlap | policy.chunk_overlap | AILINGBOT_POLICY__CHUNK_OVERLAP | 675 | 676 | 配置示例: 677 | 678 | ```toml 679 | # 使用document_qa策略,chunk_size和chunk_overlap分别配置为1000和0 680 | [policy] 681 | name = "document_qa" 682 | chunk_size = 1000 683 | chunk_overlap = 0 684 | ``` 685 | 686 | #### 模型配置 687 | 688 | 模型配置与LangChain保持一致,下面给出示例。 689 | 690 | ##### OpenAI 691 | 692 | ```toml 693 | [policy.llm] 694 | _type = "openai" # 对应环境变量AILINGBOT_POLICY__LLM___TYPE 695 | model_name = "gpt-3.5-turbo" # 对应环境变量AILINGBOT_POLICY__LLM__MODEL_NAME 696 | openai_api_key = "sk-pd8******************************HQQS241dNrHH1kv" # 对应环境变量AILINGBOT_POLICY__LLM__OPENAI_API_KEY 697 | temperature = 0 # 对应环境变量AILINGBOT_POLICY__LLM__TEMPERATURE 698 | ``` 699 | 700 | #### 内置Channel配置 701 | 702 | ##### 企业微信 703 | 704 | | 配置项 | 说明 | TOML | 环境变量 | 705 | |-------------|---------------------------|--------------------|-------------------------------| 706 | | Corp ID | 企业微信自建app的corpid | channel.corpid | AILINGBOT_CHANNEL__CORPID | 707 | | Corp Secret | 企业微信自建app的corpsecret | channel.corpsecret | AILINGBOT_CHANNEL__CORPSECRET | 708 | | Agent ID | 企业微信自建app的agentid | channel.agentid | AILINGBOT_CHANNEL__AGENTID | 709 | | TOKEN | 企业微信自建app的webhook token | channel.token | AILINGBOT_CHANNEL__TOKEN | 710 | | AES KEY | 企业微信自建app的webhook aes key | channel.aes_key | AILINGBOT_CHANNEL__AES_KEY | 711 | 712 | 配置示例: 713 | 714 | ```toml 715 | [channel] 716 | name = "wechatwork" 717 | corpid = "wwb**********ddb40" 718 | corpsecret = "TG3t******************************hZslJNe5Q" 719 | agentid = 1000001 720 | token = "j9SK**********zLeJdFSYh" 721 | aes_key = "7gCwzwH******************************p1p0O8" 722 | ``` 723 | 724 | ##### 飞书 725 | 726 | | 配置项 | 说明 | TOML | 环境变量 | 727 | |--------------------|-----------------------------------|----------------------------|---------------------------------------| 728 | | App ID | 飞书自建应用的app id | channel.app_id | AILINGBOT_CHANNEL__APP_ID | 729 | | App Secret | 飞书自建应用的app secret | channel.app_secret | AILINGBOT_CHANNEL__APP_SECRET | 730 | | Verification Token | 飞书自建应用的webhook verification token | channel.verification_token | AILINGBOT_CHANNEL__VERIFICATION_TOKEN | 731 | 732 | 配置示例: 733 | 734 | ```toml 735 | [channel] 736 | name = "feishu" 737 | app_id = "cli_a**********9d00e" 738 | app_secret = "y********************cyk8AxmYVDD" 739 | verification_token = "yIJ********************7bfNHUcYH" 740 | ``` 741 | 742 | ##### 钉钉 743 | 744 | | 配置项 | 说明 | TOML | 环境变量 | 745 | |------------|-------------------|--------------------|-------------------------------| 746 | | App Key | 钉钉自建应用的app key | channel.app_key | AILINGBOT_CHANNEL__APP_KEY | 747 | | App Secret | 钉钉自建应用的app secret | channel.app_secret | AILINGBOT_CHANNEL__APP_SECRET | 748 | | Robot Code | 钉钉自建应用的robot code | channel.robot_code | AILINGBOT_CHANNEL__ROBOT_CODE | 749 | 750 | 配置示例: 751 | 752 | ```toml 753 | [channel] 754 | name = "dingtalk" 755 | app_key = "dingi**********wymdr" 756 | app_secret = "ombrcUp****************************************GL2AwObLjILUY1MzD" 757 | robot_code = "ding**********owymdr" 758 | ``` 759 | 760 | ##### Slack 761 | 762 | | 配置项 | 说明 | TOML | 环境变量 | 763 | |--------------------|----------------------------|----------------------------|---------------------------------------| 764 | | Verification Token | Slack应用的verification token | channel.verification_token | AILINGBOT_CHANNEL__VERIFICATION_TOKEN | 765 | | OAuth token | Slack应用的oauth token | channel.oauth_token | AILINGBOT_CHANNEL__OAUTH_TOKEN | 766 | 767 | 配置示例: 768 | 769 | ```toml 770 | [channel] 771 | name = "slack" 772 | verification_token = "HzBGs1**********39gZG2P0" 773 | oauth_token = "xoxb-2**********27-5**********23-if**********H1QEGUItx2Yz" 774 | ``` 775 | 776 | ## 命令行工具 777 | 778 | ### 初始化配置文件(init) 779 | 780 | #### 使用方法 781 | 782 | `init`命令将在当前目录生成配置文件settings.toml。默认情况下,将以交互方式询问用户, 783 | 可以使用`--silence`让生成过程不询问用户,直接使用默认配置。 784 | 785 | ```text 786 | Usage: ailingbot init [OPTIONS] 787 | 788 | Initialize the AilingBot environment. 789 | 790 | Options: 791 | --silence Without asking the user. 792 | --overwrite Overwrite existing file if a file with the same name already 793 | exists. 794 | --help Show this message and exit. 795 | ``` 796 | 797 | #### Options 798 | 799 | | Option | 说明 | 类型 | 备注 | 800 | |-------------|------------------------|------|----| 801 | | --silence | 不询问用户,直接生成默认配置 | Flag | | 802 | | --overwrite | 允许覆盖当前目录的settings.toml | Flag | | 803 | 804 | ### 查看当前配置(config) 805 | 806 | `config`命令将读取当前环境的配置(包括配置文件及环境变量配置,并进行合并)。 807 | 808 | #### 使用方法 809 | 810 | ```text 811 | Usage: ailingbot config [OPTIONS] 812 | 813 | Show current configuration information. 814 | 815 | Options: 816 | -k, --config-key TEXT Configuration key. 817 | --help Show this message and exit. 818 | ``` 819 | 820 | #### Options 821 | 822 | | Option | 说明 | 类型 | 备注 | 823 | |------------------|-----|--------|----------------| 824 | | -k, --config-key | 配置键 | String | 不传入的话,显示完整配置信息 | 825 | 826 | ### 启动命令行机器人(chat) 827 | 828 | `chat`命令启动一个交互式命令行机器人,用于测试当前chat policy。 829 | 830 | #### 使用方法 831 | 832 | ```text 833 | Usage: ailingbot chat [OPTIONS] 834 | 835 | Start an interactive bot conversation environment. 836 | 837 | Options: 838 | --debug Enable debug mode. 839 | --help Show this message and exit. 840 | ``` 841 | 842 | #### Options 843 | 844 | | Option | 说明 | 类型 | 备注 | 845 | |---------|-----------|------|------------------------| 846 | | --debug | 开启debug模式 | Flag | Debug模式将输出更多内容,如prompt | 847 | 848 | ### 启动Webhook服务(serve) 849 | 850 | `serve`命令启动Webhook HTTP server,用于真正实现和具体IM进行交互。 851 | 852 | #### 使用方法 853 | 854 | ```text 855 | Usage: ailingbot serve [OPTIONS] 856 | 857 | Run webhook server to receive events. 858 | 859 | Options: 860 | --log-level [TRACE|DEBUG|INFO|SUCCESS|WARNING|ERROR|CRITICAL] 861 | The minimum severity level from which logged 862 | messages should be sent to(read from 863 | environment variable AILINGBOT_LOG_LEVEL if 864 | is not passed into). [default: TRACE] 865 | --log-file TEXT STDOUT, STDERR, or file path(read from 866 | environment variable AILINGBOT_LOG_FILE if 867 | is not passed into). [default: STDERR] 868 | --help Show this message and exit. 869 | ``` 870 | 871 | #### Options 872 | 873 | | Option | 说明 | 类型 | 备注 | 874 | |-------------|---------------------|--------|-----------------------| 875 | | --log-level | 显示日志级别,将显示此级别及以上的日志 | String | 默认显示所有级别(TRACE) | 876 | | --log-file | 日志输出位置 | String | 默认情况日志打印到标准错误(STDERR) | 877 | 878 | ### 启动API服务(api) 879 | 880 | `api`命令启动API HTTP server。 881 | 882 | #### 使用方法 883 | 884 | ```text 885 | Usage: ailingbot api [OPTIONS] 886 | 887 | Run endpoint server. 888 | 889 | Options: 890 | --log-level [TRACE|DEBUG|INFO|SUCCESS|WARNING|ERROR|CRITICAL] 891 | The minimum severity level from which logged 892 | messages should be sent to(read from 893 | environment variable AILINGBOT_LOG_LEVEL if 894 | is not passed into). [default: TRACE] 895 | --log-file TEXT STDOUT, STDERR, or file path(read from 896 | environment variable AILINGBOT_LOG_FILE if 897 | is not passed into). [default: STDERR] 898 | --help Show this message and exit. 899 | ``` 900 | 901 | #### Options 902 | 903 | | Option | 说明 | 类型 | 备注 | 904 | |-------------|---------------------|--------|-----------------------| 905 | | --log-level | 显示日志级别,将显示此级别及以上的日志 | String | 默认显示所有级别(TRACE) | 906 | | --log-file | 日志输出位置 | String | 默认情况日志打印到标准错误(STDERR) | 907 | 908 | ## 🔌API 909 | 910 | TBD 911 | 912 | # 💻开发指南 913 | 914 | ## 开发总则 915 | 916 | TBD 917 | 918 | ## 开发对话策略 919 | 920 | TBD 921 | 922 | ## 开发Channel 923 | 924 | TBD 925 | 926 | # 🤔常见问题 927 | 928 | - 由于企业微信不支持上传文件事件的回调,因此企业微信暂时不能使用内置的document_qa策略 929 | - 各个IM的webhook需要公网IP,如果你暂时没有,可以考虑通过"内网穿透"方案在本地测试,具体方法请参考网上资料 930 | - 我们预期chat policy应该是无状态的,状态应该保存在外部,但是具体实现时policy仍然有可能存在本地状态(如在本地变量中存储了对话历史)。因此当uvicorn有多个worker进程时,由于没一个进程都有单独的chat 931 | policy实例,所以这些本地状态无法共享,而同一个用户的请求可能会被不同worker响应,因此导致预期之外的行为。要避免这种行为,请保证一下两个条件至少有一个被满足: 932 | - Chat policy不使用本地状态 933 | - 只启动一个uvicorn worker 934 | 935 | # 🎯发展计划 936 | 937 | - [ ] 提供完善的使用文档和开发者文档 938 | - [ ] 支持更多的Channel 939 | - [x] 企业微信 940 | - [x] 飞书 941 | - [x] 钉钉 942 | - [x] Slack 943 | - [ ] 更多请求消息类型的支持 944 | - [x] 文本请求 945 | - [ ] 图片请求 946 | - [x] 文件请求 947 | - [ ] 更多响应消息类型的支持 948 | - [x] 文本响应 949 | - [ ] 图片响应 950 | - [ ] 文件响应 951 | - [ ] Markdown响应 952 | - [ ] 表格响应 953 | - [ ] 开发更多的开箱即用的对话策略 954 | - [x] 多轮会话策略 955 | - [x] 文档问答策略 956 | - [ ] 数据库问答策略 957 | - [ ] 在线搜索问答策略 958 | - [ ] 支持通过HTTP调用独立的对话策略服务 959 | - [ ] 基础组件抽象 960 | - [ ] 大语言模型 961 | - [ ] 知识库 962 | - [ ] Tools 963 | - [ ] 支持本地模型部署 964 | - [ ] ChatGLM-6B 965 | - [x] 支持通过API调用 966 | - [ ] Web管理后台及可视化配置管理 967 | - [x] 提供基于Docker容器的部署能力 968 | - [ ] 增强系统的可观测性和可治理性 969 | - [ ] 完善的测试用例 970 | -------------------------------------------------------------------------------- /ailingbot/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ericzhang-cn/ailingbot/dd9962b021b2abb0995b0f0620c9cb24033ee0cd/ailingbot/__init__.py -------------------------------------------------------------------------------- /ailingbot/channels/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ericzhang-cn/ailingbot/dd9962b021b2abb0995b0f0620c9cb24033ee0cd/ailingbot/channels/__init__.py -------------------------------------------------------------------------------- /ailingbot/channels/channel.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import abc 4 | import typing 5 | 6 | from asgiref.typing import ASGIApplication 7 | 8 | from ailingbot.shared.abc import AbstractAsyncComponent 9 | from ailingbot.shared.misc import get_class_dynamically 10 | 11 | 12 | class ChannelAgent(AbstractAsyncComponent, abc.ABC): 13 | """Base class of channel agents.""" 14 | 15 | def __init__(self): 16 | super(ChannelAgent, self).__init__() 17 | 18 | @staticmethod 19 | def get_agent( 20 | name: str, full_class_path: typing.Optional[str] = None 21 | ) -> ChannelAgent: 22 | """Gets channel agent instance. 23 | 24 | :param name: Built-in channel name or full path of agent class. 25 | :type name: str 26 | :param full_class_path: 27 | :type full_class_path: 28 | :return: Agent instance. 29 | :rtype: ChannelAgent 30 | """ 31 | if name.lower() == 'wechatwork': 32 | from ailingbot.channels.wechatwork.agent import WechatworkAgent 33 | 34 | instance = WechatworkAgent() 35 | elif name.lower() == 'feishu': 36 | from ailingbot.channels.feishu.agent import FeishuAgent 37 | 38 | instance = FeishuAgent() 39 | elif name.lower() == 'dingtalk': 40 | from ailingbot.channels.dingtalk.agent import DingtalkAgent 41 | 42 | instance = DingtalkAgent() 43 | else: 44 | instance = get_class_dynamically(full_class_path)() 45 | 46 | return instance 47 | 48 | 49 | class ChannelWebhookFactory(abc.ABC): 50 | """Base class of channel webhook factories.""" 51 | 52 | def __init__(self, *, debug: bool = False): 53 | self.debug = debug 54 | 55 | async def create_webhook_app(self) -> ASGIApplication | typing.Callable: 56 | """Creates a ASGI application. 57 | 58 | :return: ASGI application. 59 | :rtype: typing.Union[ASGIApplication, typing.Callable] 60 | """ 61 | raise NotImplementedError 62 | 63 | @staticmethod 64 | async def get_webhook( 65 | name: str, 66 | full_class_path: typing.Optional[str] = None, 67 | debug: bool = False, 68 | ) -> ASGIApplication | typing.Callable: 69 | """Gets channel webhook ASGI application instance. 70 | 71 | :param name: Built-in channel name or full path of webhook factory class. 72 | :type name: str 73 | :param full_class_path: 74 | :type full_class_path: 75 | :param debug: 76 | :type debug: 77 | :return: Webhook ASGI application. 78 | :rtype: typing.Union[ASGIApplication, typing.Callable] 79 | """ 80 | if name.lower() == 'wechatwork': 81 | from ailingbot.channels.wechatwork.webhook import ( 82 | WechatworkWebhookFactory, 83 | ) 84 | 85 | factory = WechatworkWebhookFactory(debug=debug) 86 | elif name.lower() == 'feishu': 87 | from ailingbot.channels.feishu.webhook import ( 88 | FeishuWebhookFactory, 89 | ) 90 | 91 | factory = FeishuWebhookFactory(debug=debug) 92 | 93 | elif name.lower() == 'dingtalk': 94 | from ailingbot.channels.dingtalk.webhook import ( 95 | DingtalkWebhookFactory, 96 | ) 97 | 98 | factory = DingtalkWebhookFactory(debug=debug) 99 | elif name.lower() == 'slack': 100 | from ailingbot.channels.slack.webhook import ( 101 | SlackWebhookFactory, 102 | ) 103 | 104 | factory = SlackWebhookFactory(debug=debug) 105 | else: 106 | factory = get_class_dynamically(full_class_path)(debug=debug) 107 | 108 | return await factory.create_webhook_app() 109 | -------------------------------------------------------------------------------- /ailingbot/channels/dingtalk/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ericzhang-cn/ailingbot/dd9962b021b2abb0995b0f0620c9cb24033ee0cd/ailingbot/channels/dingtalk/__init__.py -------------------------------------------------------------------------------- /ailingbot/channels/dingtalk/agent.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | import typing 5 | 6 | import aiohttp 7 | import arrow 8 | 9 | from ailingbot.channels.channel import ChannelAgent 10 | from ailingbot.channels.dingtalk.render import render 11 | from ailingbot.chat.messages import ResponseMessage, MessageScope 12 | from ailingbot.config import settings 13 | from ailingbot.shared.errors import ExternalHTTPAPIError 14 | 15 | 16 | class DingtalkAgent(ChannelAgent): 17 | """Dingtalk channel agent class.""" 18 | 19 | def __init__(self): 20 | """Initializes class.""" 21 | super(DingtalkAgent, self).__init__() 22 | 23 | self.app_key = settings.channel.app_key 24 | self.app_secret = settings.channel.app_secret 25 | self.robot_code = settings.channel.robot_code 26 | self.access_token: typing.Optional[str] = None 27 | self.expire_in: typing.Optional[arrow.Arrow] = None 28 | 29 | async def _get_access_token(self) -> str: 30 | """Gets Dingtalk API access token. 31 | 32 | Returns cached token if not expired, otherwise, refreshes token. 33 | 34 | :return: Access token. 35 | :rtype: str 36 | """ 37 | # Returns cached token if not expired. 38 | if self.expire_in is not None and arrow.now() < self.expire_in: 39 | return self.access_token 40 | 41 | async with aiohttp.ClientSession() as session: 42 | async with session.get( 43 | 'https://oapi.dingtalk.com/gettoken', 44 | params={ 45 | 'appkey': self.app_key, 46 | 'appsecret': self.app_secret, 47 | }, 48 | ) as response: 49 | if not response.ok: 50 | response.raise_for_status() 51 | body = await response.json() 52 | 53 | if body.get('errcode', -1) != 0: 54 | raise ExternalHTTPAPIError(body.get('errmsg', '')) 55 | access_token, expires_in = body.get('access_token', ''), body.get( 56 | 'expires_in', 0 57 | ) 58 | self.access_token = access_token 59 | self.expire_in = arrow.now().shift(seconds=(expires_in - 120)) 60 | return access_token 61 | 62 | def _clean_access_token(self) -> None: 63 | """Cleans up access token to force refreshing token.""" 64 | self.access_token = None 65 | self.expire_in = None 66 | 67 | async def _send_to_users( 68 | self, *, user_ids: list[str], body: dict[str, typing.Any] 69 | ) -> None: 70 | """Sends message to a batch of user using Dingtalk API. 71 | 72 | :param body: Request body parameters. 73 | :type body: typing.Dict[str, typing.Any] 74 | """ 75 | access_token = await self._get_access_token() 76 | async with aiohttp.ClientSession() as session: 77 | body['userIds'] = user_ids 78 | async with session.post( 79 | 'https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend', 80 | json=body, 81 | headers={ 82 | 'x-acs-dingtalk-access-token': access_token, 83 | 'Content-Type': 'application/json; charset=utf-8', 84 | }, 85 | ) as response: 86 | if not response.ok: 87 | response.raise_for_status() 88 | body = await response.json() 89 | if body.get('processQueryKey', None) is None: 90 | raise ExternalHTTPAPIError(body.get('message', '')) 91 | 92 | async def _send_to_group( 93 | self, *, open_conversation_id: str, body: dict[str, typing.Any] 94 | ) -> None: 95 | """Replies message using Feishu API. 96 | 97 | :param body: Request body parameters. 98 | :type body: typing.Dict[str, typing.Any] 99 | """ 100 | access_token = await self._get_access_token() 101 | async with aiohttp.ClientSession() as session: 102 | body['openConversationId'] = open_conversation_id 103 | async with session.post( 104 | 'https://api.dingtalk.com/v1.0/robot/groupMessages/send', 105 | json=body, 106 | headers={ 107 | 'x-acs-dingtalk-access-token': access_token, 108 | 'Content-Type': 'application/json; charset=utf-8', 109 | }, 110 | ) as response: 111 | if not response.ok: 112 | response.raise_for_status() 113 | body = await response.json() 114 | if body.get('processQueryKey', None) is None: 115 | raise ExternalHTTPAPIError(body.get('message', '')) 116 | 117 | async def send_message(self, message: ResponseMessage) -> None: 118 | """Using Dingtalk agent to send message.""" 119 | try: 120 | content, message_type = await render(message) 121 | except NotImplementedError: 122 | content, message_type = await render( 123 | message.downgrade_to_text_message() 124 | ) 125 | body = { 126 | 'msgParam': json.dumps(content, ensure_ascii=False), 127 | 'msgKey': message_type, 128 | 'robotCode': self.robot_code, 129 | } 130 | if message.scope == MessageScope.USER: 131 | await self._send_to_users( 132 | user_ids=message.echo.get('staff_ids', []), body=body 133 | ) 134 | elif message.scope == MessageScope.GROUP: 135 | await self._send_to_group( 136 | open_conversation_id=message.echo.get('conversation_id', ''), 137 | body=body, 138 | ) 139 | 140 | async def download_file(self, *, download_code: str) -> bytes: 141 | """Download file.""" 142 | access_token = await self._get_access_token() 143 | async with aiohttp.ClientSession() as session: 144 | async with session.post( 145 | 'https://api.dingtalk.com/v1.0/robot/messageFiles/download', 146 | json={ 147 | 'downloadCode': download_code, 148 | 'robotCode': self.robot_code, 149 | }, 150 | headers={ 151 | 'x-acs-dingtalk-access-token': access_token, 152 | 'Content-Type': 'application/json; charset=utf-8', 153 | }, 154 | ) as response: 155 | if not response.ok: 156 | response.raise_for_status() 157 | body = await response.json() 158 | if body.get('downloadUrl', None) is None: 159 | raise ExternalHTTPAPIError(body.get('message', '')) 160 | 161 | async with aiohttp.ClientSession() as session: 162 | async with session.get( 163 | body.get('downloadUrl', ''), 164 | ) as response: 165 | if not response.ok: 166 | response.raise_for_status() 167 | return await response.content.read() 168 | -------------------------------------------------------------------------------- /ailingbot/channels/dingtalk/render.py: -------------------------------------------------------------------------------- 1 | import functools 2 | 3 | from ailingbot.chat.messages import ( 4 | ResponseMessage, 5 | TextResponseMessage, 6 | ) 7 | 8 | 9 | @functools.singledispatch 10 | async def render(response: ResponseMessage) -> tuple[dict, str]: 11 | """Virtual function of all response message renders. 12 | 13 | Converts response message to Feishu content. 14 | 15 | :param response: Response message. 16 | :type response: ResponseMessage 17 | :return: Render result and Feishu message type. 18 | :rtype: typing.Tuple[dict, str]: 19 | """ 20 | raise NotImplementedError 21 | 22 | 23 | @render.register 24 | async def _render(response: TextResponseMessage) -> tuple[dict, str]: 25 | """Renders text response message.""" 26 | content = { 27 | 'content': response.text, 28 | } 29 | message_type = 'sampleText' 30 | return content, message_type 31 | -------------------------------------------------------------------------------- /ailingbot/channels/dingtalk/webhook.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing 4 | 5 | from asgiref.typing import ASGIApplication 6 | from fastapi import FastAPI, status 7 | from pydantic import BaseModel 8 | from starlette.background import BackgroundTasks 9 | 10 | from ailingbot.channels.channel import ChannelWebhookFactory 11 | from ailingbot.channels.dingtalk.agent import DingtalkAgent 12 | from ailingbot.chat.chatbot import ChatBot 13 | from ailingbot.chat.messages import ( 14 | TextRequestMessage, 15 | MessageScope, 16 | FileRequestMessage, 17 | ) 18 | 19 | 20 | class DingtalkMessage(BaseModel): 21 | conversationId: typing.Optional[str] 22 | msgId: typing.Optional[str] 23 | conversationType: typing.Optional[str] 24 | senderId: typing.Optional[str] 25 | senderStaffId: typing.Optional[str] 26 | senderNick: typing.Optional[str] 27 | msgtype: typing.Optional[str] 28 | text: typing.Optional[dict] 29 | content: typing.Optional[dict] 30 | 31 | 32 | class DingtalkWebhookFactory(ChannelWebhookFactory): 33 | """Factory that creates Dingtalk webhook ASGI application.""" 34 | 35 | def __init__(self, *, debug: bool = False): 36 | super(DingtalkWebhookFactory, self).__init__(debug=debug) 37 | 38 | self.app: typing.Optional[ASGIApplication | typing.Callable] = None 39 | self.agent: typing.Optional[DingtalkAgent] = None 40 | self.bot: typing.Optional[ChatBot] = None 41 | 42 | async def create_webhook_app(self) -> ASGIApplication | typing.Callable: 43 | self.app = FastAPI() 44 | self.agent = DingtalkAgent() 45 | self.bot = ChatBot(debug=self.debug) 46 | 47 | async def _download_file() -> bytes: 48 | return b'' 49 | 50 | async def _chat_task( 51 | conversation_id: str, dingtalk_message: DingtalkMessage 52 | ) -> None: 53 | """Send a request message to the bot, receive a response message, and send it back to the user.""" 54 | 55 | if dingtalk_message.msgtype == 'text': 56 | req_msg = TextRequestMessage( 57 | text=dingtalk_message.text.get('content', ''), 58 | ) 59 | elif dingtalk_message.msgtype == 'file': 60 | dingtalk = DingtalkAgent() 61 | file_name = dingtalk_message.content.get('fileName', '') 62 | if len(file_name.split('.')) >= 2: 63 | file_type = file_name.split('.')[-1].strip().lower() 64 | else: 65 | file_type = '' 66 | file_content = await dingtalk.download_file( 67 | download_code=dingtalk_message.content.get( 68 | 'downloadCode', '' 69 | ) 70 | ) 71 | req_msg = FileRequestMessage( 72 | file_name=file_name, 73 | file_type=file_type, 74 | content=file_content, 75 | ) 76 | else: 77 | return 78 | 79 | req_msg.uuid = dingtalk_message.msgId 80 | req_msg.sender_id = dingtalk_message.senderId 81 | if dingtalk_message.conversationType == '1': 82 | req_msg.scope = MessageScope.USER 83 | req_msg.echo['staff_ids'] = [dingtalk_message.senderStaffId] 84 | elif dingtalk_message.conversationType == '2': 85 | req_msg.scope = MessageScope.GROUP 86 | req_msg.echo[ 87 | 'conversation_id' 88 | ] = dingtalk_message.conversationId 89 | 90 | response = await self.bot.chat( 91 | conversation_id=conversation_id, message=req_msg 92 | ) 93 | await self.agent.send_message(response) 94 | 95 | @self.app.on_event('startup') 96 | async def startup() -> None: 97 | await self.agent.initialize() 98 | await self.bot.initialize() 99 | 100 | @self.app.on_event('shutdown') 101 | async def shutdown() -> None: 102 | await self.agent.finalize() 103 | await self.bot.finalize() 104 | 105 | @self.app.post( 106 | '/webhook/dingtalk/event/', status_code=status.HTTP_200_OK 107 | ) 108 | async def handle_event( 109 | message: DingtalkMessage, 110 | background_tasks: BackgroundTasks, 111 | ) -> dict: 112 | """Handle the message request from Dingtalk. 113 | 114 | :return: Empty dict. 115 | :rtype: dict 116 | """ 117 | 118 | background_tasks.add_task( 119 | _chat_task, message.conversationId, message 120 | ) 121 | 122 | return {} 123 | 124 | return self.app 125 | -------------------------------------------------------------------------------- /ailingbot/channels/feishu/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ericzhang-cn/ailingbot/dd9962b021b2abb0995b0f0620c9cb24033ee0cd/ailingbot/channels/feishu/__init__.py -------------------------------------------------------------------------------- /ailingbot/channels/feishu/agent.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | import typing 5 | 6 | import aiohttp 7 | import arrow 8 | 9 | from ailingbot.channels.channel import ChannelAgent 10 | from ailingbot.channels.feishu.render import render 11 | from ailingbot.chat.messages import ResponseMessage, MessageScope 12 | from ailingbot.config import settings 13 | from ailingbot.shared.errors import ExternalHTTPAPIError 14 | 15 | 16 | class FeishuAgent(ChannelAgent): 17 | """Feishu channel agent class.""" 18 | 19 | def __init__(self): 20 | """Initializes class.""" 21 | super(FeishuAgent, self).__init__() 22 | 23 | self.app_id = settings.channel.app_id 24 | self.app_secret = settings.channel.app_secret 25 | self.access_token: typing.Optional[str] = None 26 | self.expire_in: typing.Optional[arrow.Arrow] = None 27 | 28 | async def _get_access_token(self) -> str: 29 | """Gets Feishu API access token. 30 | 31 | Returns cached token if not expired, otherwise, refreshes token. 32 | 33 | :return: Access token. 34 | :rtype: str 35 | """ 36 | # Returns cached token if not expired. 37 | if self.expire_in is not None and arrow.now() < self.expire_in: 38 | return self.access_token 39 | 40 | async with aiohttp.ClientSession() as session: 41 | async with session.post( 42 | 'https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal', 43 | json={ 44 | 'app_id': self.app_id, 45 | 'app_secret': self.app_secret, 46 | }, 47 | ) as response: 48 | if not response.ok: 49 | response.raise_for_status() 50 | body = await response.json() 51 | 52 | if body.get('code', -1) != 0: 53 | raise ExternalHTTPAPIError(body.get('msg', '')) 54 | access_token, expires_in = body.get( 55 | 'tenant_access_token', '' 56 | ), body.get('expire', 0) 57 | self.access_token = access_token 58 | self.expire_in = arrow.now().shift(seconds=(expires_in - 120)) 59 | return access_token 60 | 61 | def _clean_access_token(self) -> None: 62 | """Cleans up access token to force refreshing token.""" 63 | self.access_token = None 64 | self.expire_in = None 65 | 66 | async def _send( 67 | self, *, receive_id_type: str, body: dict[str, typing.Any] 68 | ) -> None: 69 | """Sends message using Feishu API. 70 | 71 | :param body: Request body parameters. 72 | :type body: typing.Dict[str, typing.Any] 73 | """ 74 | access_token = await self._get_access_token() 75 | async with aiohttp.ClientSession() as session: 76 | async with session.post( 77 | 'https://open.feishu.cn/open-apis/im/v1/messages', 78 | json=body, 79 | headers={ 80 | 'Authorization': f'Bearer {access_token}', 81 | 'Content-Type': 'application/json; charset=utf-8', 82 | }, 83 | params={'receive_id_type': receive_id_type}, 84 | ) as response: 85 | if not response.ok: 86 | response.raise_for_status() 87 | body = await response.json() 88 | if body.get('code', -1) != 0: 89 | raise ExternalHTTPAPIError(body.get('msg', '')) 90 | 91 | async def _reply( 92 | self, *, ack_uuid: str, body: dict[str, typing.Any] 93 | ) -> None: 94 | """Replies message using Feishu API. 95 | 96 | :param body: Request body parameters. 97 | :type body: typing.Dict[str, typing.Any] 98 | """ 99 | access_token = await self._get_access_token() 100 | async with aiohttp.ClientSession() as session: 101 | async with session.post( 102 | f'https://open.feishu.cn/open-apis/im/v1/messages/{ack_uuid}/reply', 103 | json=body, 104 | headers={ 105 | 'Authorization': f'Bearer {access_token}', 106 | 'Content-Type': 'application/json; charset=utf-8', 107 | }, 108 | ) as response: 109 | if not response.ok: 110 | response.raise_for_status() 111 | body = await response.json() 112 | if body.get('code', -1) != 0: 113 | raise ExternalHTTPAPIError(body.get('msg', '')) 114 | 115 | async def send_message(self, message: ResponseMessage) -> None: 116 | """Using Feishu agent to send message.""" 117 | try: 118 | content, message_type = await render(message) 119 | except NotImplementedError: 120 | content, message_type = await render( 121 | message.downgrade_to_text_message() 122 | ) 123 | body = { 124 | 'msg_type': message_type, 125 | 'content': json.dumps(content), 126 | 'uuid': message.uuid, 127 | } 128 | if message.scope == MessageScope.USER: 129 | receive_id_type = 'open_id' 130 | elif message.scope == MessageScope.GROUP: 131 | receive_id_type = 'chat_id' 132 | else: 133 | receive_id_type = 'open_id' 134 | 135 | if message.ack_uuid: 136 | await self._reply(ack_uuid=message.ack_uuid, body=body) 137 | else: 138 | body['receive_id'] = message.receiver_id 139 | await self._send(receive_id_type=receive_id_type, body=body) 140 | 141 | async def get_resource_from_message( 142 | self, message_id: str, file_key: str, resource_type: str 143 | ) -> bytes: 144 | """Get file or image resource from message.""" 145 | access_token = await self._get_access_token() 146 | async with aiohttp.ClientSession() as session: 147 | async with session.get( 148 | f'https://open.feishu.cn/open-apis/im/v1/messages/{message_id}/resources/{file_key}', 149 | headers={ 150 | 'Authorization': f'Bearer {access_token}', 151 | }, 152 | params={'type': resource_type}, 153 | ) as response: 154 | if not response.ok: 155 | response.raise_for_status() 156 | return await response.content.read() 157 | -------------------------------------------------------------------------------- /ailingbot/channels/feishu/render.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import json 3 | 4 | from ailingbot.chat.messages import ( 5 | ResponseMessage, 6 | TextResponseMessage, 7 | FallbackResponseMessage, 8 | ) 9 | 10 | 11 | @functools.singledispatch 12 | async def render(response: ResponseMessage) -> tuple[dict, str]: 13 | """Virtual function of all response message renders. 14 | 15 | Converts response message to Feishu content. 16 | 17 | :param response: Response message. 18 | :type response: ResponseMessage 19 | :return: Render result and Feishu message type. 20 | :rtype: typing.Tuple[dict, str]: 21 | """ 22 | raise NotImplementedError 23 | 24 | 25 | @render.register 26 | async def _render(response: TextResponseMessage) -> tuple[dict, str]: 27 | """Renders text response message.""" 28 | content = { 29 | 'text': response.text, 30 | } 31 | message_type = 'text' 32 | return content, message_type 33 | 34 | 35 | @render.register 36 | async def _render(response: FallbackResponseMessage) -> tuple[dict, str]: 37 | """Renders text response message.""" 38 | content = { 39 | 'config': {'wide_screen_mode': True}, 40 | 'elements': [ 41 | { 42 | 'tag': 'markdown', 43 | 'content': f'**原因**:{response.reason}\n**建议**:{response.suggestion}', 44 | } 45 | ], 46 | 'header': { 47 | 'template': 'orange', 48 | 'title': {'content': '🤔出了一些问题', 'tag': 'plain_text'}, 49 | }, 50 | } 51 | message_type = 'interactive' 52 | return content, message_type 53 | -------------------------------------------------------------------------------- /ailingbot/channels/feishu/webhook.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | import typing 5 | 6 | from asgiref.typing import ASGIApplication 7 | from cachetools import LRUCache 8 | from fastapi import FastAPI, status, HTTPException 9 | from pydantic import BaseModel 10 | from starlette.background import BackgroundTasks 11 | 12 | from ailingbot.channels.channel import ChannelWebhookFactory 13 | from ailingbot.channels.feishu.agent import FeishuAgent 14 | from ailingbot.chat.chatbot import ChatBot 15 | from ailingbot.chat.messages import ( 16 | TextRequestMessage, 17 | MessageScope, 18 | FileRequestMessage, 19 | ) 20 | from ailingbot.config import settings 21 | 22 | 23 | class FeishuEventBodyHeader(BaseModel): 24 | event_id: typing.Optional[str] 25 | event_type: typing.Optional[str] 26 | create_time: typing.Optional[str] 27 | token: typing.Optional[str] 28 | app_id: typing.Optional[str] 29 | tenant_key: typing.Optional[str] 30 | 31 | 32 | class FeishuEventBodyEventSender(BaseModel): 33 | sender_id: typing.Optional[dict[str, str]] 34 | sender_type: typing.Optional[str] 35 | tenant_key: typing.Optional[str] 36 | 37 | 38 | class FeishuEventBodyEventMessage(BaseModel): 39 | message_id: typing.Optional[str] 40 | root_id: typing.Optional[str] 41 | parent_id: typing.Optional[str] 42 | create_time: typing.Optional[str] 43 | chat_id: typing.Optional[str] 44 | chat_type: typing.Optional[str] 45 | message_type: typing.Optional[str] 46 | content: typing.Optional[str] 47 | 48 | 49 | class FeishuEventBodyEvent(BaseModel): 50 | sender: typing.Optional[FeishuEventBodyEventSender] 51 | message: typing.Optional[FeishuEventBodyEventMessage] 52 | 53 | 54 | class FeishuEventBody(BaseModel): 55 | """The event body of Feishu message.""" 56 | 57 | challenge: typing.Optional[str] 58 | token: typing.Optional[str] 59 | type: typing.Optional[str] 60 | header: typing.Optional[FeishuEventBodyHeader] 61 | event: typing.Optional[FeishuEventBodyEvent] 62 | 63 | 64 | class FeishuWebhookFactory(ChannelWebhookFactory): 65 | """Factory that creates Feishu webhook ASGI application.""" 66 | 67 | def __init__(self, *, debug: bool = False): 68 | super(FeishuWebhookFactory, self).__init__(debug=debug) 69 | 70 | self.verification_token = settings.channel.verification_token 71 | 72 | self.app: typing.Optional[ASGIApplication | typing.Callable] = None 73 | self.agent: typing.Optional[FeishuAgent] = None 74 | self.bot: typing.Optional[ChatBot] = None 75 | self.event_id_cache: typing.Optional[LRUCache] = None 76 | 77 | async def create_webhook_app(self) -> ASGIApplication | typing.Callable: 78 | self.app = FastAPI() 79 | self.agent = FeishuAgent() 80 | self.bot = ChatBot(debug=self.debug) 81 | self.event_id_cache = LRUCache(maxsize=1024) 82 | 83 | async def _chat_task( 84 | conversation_id: str, event: FeishuEventBody 85 | ) -> None: 86 | """Send a request message to the bot, receive a response message, and send it back to the user.""" 87 | if event.header.event_id in self.event_id_cache: 88 | return 89 | self.event_id_cache[event.header.event_id] = True 90 | 91 | if event.event.message.message_type == 'text': 92 | req_msg = _create_text_request_message(event) 93 | elif event.event.message.message_type == 'file': 94 | req_msg = await _create_file_request_message(event) 95 | else: 96 | return 97 | 98 | req_msg.uuid = event.event.message.message_id 99 | req_msg.sender_id = event.event.sender.sender_id.get('open_id', '') 100 | if event.event.message.chat_type == 'p2p': 101 | req_msg.scope = MessageScope.USER 102 | elif event.event.message.chat_type == 'group': 103 | req_msg.scope = MessageScope.GROUP 104 | 105 | response = await self.bot.chat( 106 | conversation_id=conversation_id, message=req_msg 107 | ) 108 | await self.agent.send_message(response) 109 | 110 | @self.app.on_event('startup') 111 | async def startup() -> None: 112 | await self.agent.initialize() 113 | await self.bot.initialize() 114 | 115 | @self.app.on_event('shutdown') 116 | async def shutdown() -> None: 117 | await self.agent.finalize() 118 | await self.bot.finalize() 119 | 120 | def _create_text_request_message( 121 | event: FeishuEventBody, 122 | ) -> TextRequestMessage: 123 | text = json.loads(event.event.message.content).get('text', '') 124 | text = ' '.join( 125 | [ 126 | x 127 | for x in text.split(' ') 128 | if not x.strip().startswith('@_user_') 129 | ] 130 | ) 131 | return TextRequestMessage( 132 | text=text, 133 | ) 134 | 135 | async def _create_file_request_message( 136 | event: FeishuEventBody, 137 | ) -> FileRequestMessage: 138 | content = json.loads(event.event.message.content) 139 | file_key = content.get('file_key', '') 140 | file_name = content.get('file_name', '') 141 | 142 | feishu = FeishuAgent() 143 | content = await feishu.get_resource_from_message( 144 | event.event.message.message_id, file_key, 'file' 145 | ) 146 | if len(file_name.split('.')) >= 2: 147 | file_type = file_name.split('.')[-1].strip().lower() 148 | else: 149 | file_type = '' 150 | 151 | return FileRequestMessage( 152 | content=content, 153 | file_type=file_type, 154 | file_name=file_name, 155 | ) 156 | 157 | @self.app.post( 158 | '/webhook/feishu/event/', status_code=status.HTTP_200_OK 159 | ) 160 | async def handle_event( 161 | event: FeishuEventBody, 162 | background_tasks: BackgroundTasks, 163 | ) -> dict: 164 | """Handle the event request from Feishu. 165 | 166 | :return: Empty dict. 167 | :rtype: dict 168 | """ 169 | if event.type and event.type == 'url_verification': 170 | if not event.token or event.token != self.verification_token: 171 | raise HTTPException( 172 | status_code=status.HTTP_401_UNAUTHORIZED, 173 | detail='Invalid verification token.', 174 | ) 175 | else: 176 | return { 177 | 'challenge': event.challenge, 178 | } 179 | 180 | if event.header.token != self.verification_token: 181 | raise HTTPException( 182 | status_code=status.HTTP_401_UNAUTHORIZED, 183 | detail='Invalid verification token.', 184 | ) 185 | if event.header.event_type != 'im.message.receive_v1': 186 | raise HTTPException( 187 | status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, 188 | detail='Event type is not supported.', 189 | ) 190 | if event.event.message.message_type not in ['text', 'file']: 191 | raise HTTPException( 192 | status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, 193 | detail='Message type is not supported.', 194 | ) 195 | 196 | background_tasks.add_task( 197 | _chat_task, event.event.message.chat_id, event 198 | ) 199 | 200 | return {} 201 | 202 | return self.app 203 | -------------------------------------------------------------------------------- /ailingbot/channels/slack/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ericzhang-cn/ailingbot/dd9962b021b2abb0995b0f0620c9cb24033ee0cd/ailingbot/channels/slack/__init__.py -------------------------------------------------------------------------------- /ailingbot/channels/slack/agent.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import aiohttp 4 | 5 | from ailingbot.channels.channel import ChannelAgent 6 | from ailingbot.chat.messages import ResponseMessage, MessageScope 7 | from ailingbot.config import settings 8 | from ailingbot.shared.errors import ExternalHTTPAPIError 9 | 10 | 11 | class SlackAgent(ChannelAgent): 12 | """Slack channel agent class.""" 13 | 14 | def __init__(self): 15 | """Initializes class.""" 16 | super(SlackAgent, self).__init__() 17 | 18 | self.oauth_token = settings.channel.oauth_token 19 | 20 | async def _send( 21 | self, 22 | *, 23 | channel: str, 24 | thread_ts: str = '', 25 | text: str, 26 | ) -> None: 27 | """Sends message using Slack API.""" 28 | async with aiohttp.ClientSession() as session: 29 | body = { 30 | 'channel': channel, 31 | 'text': text, 32 | } 33 | if thread_ts: 34 | body['thread_ts'] = thread_ts 35 | async with session.post( 36 | 'https://slack.com/api/chat.postMessage', 37 | json=body, 38 | headers={ 39 | 'Authorization': f'Bearer {self.oauth_token}', 40 | 'Content-Type': 'application/json; charset=utf-8', 41 | }, 42 | ) as response: 43 | if not response.ok: 44 | response.raise_for_status() 45 | body = await response.json() 46 | if not body.get('ok', False): 47 | raise ExternalHTTPAPIError(body.get('error', '')) 48 | 49 | async def send_message(self, message: ResponseMessage) -> None: 50 | """Using Slack agent to send message.""" 51 | text = message.downgrade_to_text_message().text 52 | channel = message.echo['channel'] 53 | thread_ts = message.ack_uuid 54 | if message.scope == MessageScope.USER: 55 | await self._send(channel=channel, text=text) 56 | elif message.scope == MessageScope.GROUP: 57 | # await self._send(channel=channel, text=text, thread_ts=thread_ts) 58 | await self._send(channel=channel, text=text) 59 | 60 | async def download_file(self, url: str) -> bytes: 61 | async with aiohttp.ClientSession() as session: 62 | async with session.get( 63 | url, 64 | headers={ 65 | 'Authorization': f'Bearer {self.oauth_token}', 66 | }, 67 | ) as response: 68 | if not response.ok: 69 | response.raise_for_status() 70 | return await response.content.read() 71 | -------------------------------------------------------------------------------- /ailingbot/channels/slack/webhook.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | import typing 5 | 6 | from asgiref.typing import ASGIApplication 7 | from fastapi import FastAPI, status, Request, Response, HTTPException 8 | from starlette.background import BackgroundTasks 9 | 10 | from ailingbot.channels.channel import ChannelWebhookFactory 11 | from ailingbot.channels.slack.agent import SlackAgent 12 | from ailingbot.chat.chatbot import ChatBot 13 | from ailingbot.chat.messages import ( 14 | TextRequestMessage, 15 | MessageScope, 16 | FileRequestMessage, 17 | ) 18 | from ailingbot.config import settings 19 | 20 | 21 | class SlackWebhookFactory(ChannelWebhookFactory): 22 | """Factory that creates Slack webhook ASGI application.""" 23 | 24 | def __init__(self, *, debug: bool = False): 25 | super(SlackWebhookFactory, self).__init__(debug=debug) 26 | 27 | self.verification_token = settings.channel.verification_token 28 | 29 | self.app: typing.Optional[ASGIApplication | typing.Callable] = None 30 | self.agent: typing.Optional[SlackAgent] = None 31 | self.bot: typing.Optional[ChatBot] = None 32 | 33 | async def create_webhook_app(self) -> ASGIApplication | typing.Callable: 34 | self.app = FastAPI() 35 | self.agent = SlackAgent() 36 | self.bot = ChatBot(debug=self.debug) 37 | 38 | async def _chat_task(conversation_id: str, event: dict) -> None: 39 | """Send a request message to the bot, receive a response message, and send it back to the user.""" 40 | if 'bot_id' in event: 41 | return 42 | 43 | if 'files' in event: 44 | file_name = event['files'][0]['name'] 45 | file_type = event['files'][0]['filetype'].lower() 46 | url = event['files'][0]['url_private_download'] 47 | content = await self.agent.download_file(url) 48 | req_msg = FileRequestMessage( 49 | file_name=file_name, 50 | file_type=file_type, 51 | content=content, 52 | ) 53 | elif 'text' in event: 54 | text = re.sub(r'<@\w+>', '', event['text']) 55 | req_msg = TextRequestMessage( 56 | text=text, 57 | ) 58 | else: 59 | return 60 | 61 | req_msg.uuid = event['event_ts'] 62 | req_msg.sender_id = event['user'] 63 | req_msg.echo['channel'] = event['channel'] 64 | if event.get('channel_type', '') == 'im': 65 | req_msg.scope = MessageScope.USER 66 | else: 67 | req_msg.scope = MessageScope.GROUP 68 | 69 | response = await self.bot.chat( 70 | conversation_id=conversation_id, message=req_msg 71 | ) 72 | await self.agent.send_message(response) 73 | 74 | @self.app.on_event('startup') 75 | async def startup() -> None: 76 | await self.agent.initialize() 77 | await self.bot.initialize() 78 | 79 | @self.app.on_event('shutdown') 80 | async def shutdown() -> None: 81 | await self.agent.finalize() 82 | await self.bot.finalize() 83 | 84 | @self.app.post('/webhook/slack/event/', status_code=status.HTTP_200_OK) 85 | async def handle_event( 86 | request: Request, 87 | background_tasks: BackgroundTasks, 88 | ) -> dict | Response: 89 | """Handle the event request from Slack. 90 | 91 | :return: Empty dict. 92 | :rtype: dict 93 | """ 94 | message = await request.json() 95 | if message.get('token', '') != self.verification_token: 96 | raise HTTPException( 97 | status_code=status.HTTP_401_UNAUTHORIZED, 98 | detail='Invalid verification token.', 99 | ) 100 | 101 | if 'challenge' in message: 102 | return Response( 103 | content=message.get('challenge'), media_type='text/plain' 104 | ) 105 | 106 | if message.get('event', {}).get('type') not in [ 107 | 'message', 108 | 'app_mention', 109 | ]: 110 | raise HTTPException( 111 | status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, 112 | detail='Event type is not supported.', 113 | ) 114 | 115 | background_tasks.add_task( 116 | _chat_task, 117 | message.get('event', {}).get('channel', ''), 118 | message.get('event', {}), 119 | ) 120 | 121 | return {} 122 | 123 | return self.app 124 | -------------------------------------------------------------------------------- /ailingbot/channels/wechatwork/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ericzhang-cn/ailingbot/dd9962b021b2abb0995b0f0620c9cb24033ee0cd/ailingbot/channels/wechatwork/__init__.py -------------------------------------------------------------------------------- /ailingbot/channels/wechatwork/agent.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing 4 | 5 | import aiohttp 6 | import arrow 7 | 8 | from ailingbot.channels.channel import ChannelAgent 9 | from ailingbot.channels.wechatwork.render import render 10 | from ailingbot.chat.messages import ResponseMessage, MessageScope 11 | from ailingbot.config import settings 12 | from ailingbot.shared.errors import ExternalHTTPAPIError 13 | 14 | 15 | class WechatworkAgent(ChannelAgent): 16 | """Wechatwork channel agent class.""" 17 | 18 | def __init__(self): 19 | """Initializes class.""" 20 | super(WechatworkAgent, self).__init__() 21 | 22 | self.corpid = settings.channel.corpid 23 | self.corpsecret = settings.channel.corpsecret 24 | self.agentid = settings.channel.agentid 25 | self.access_token: typing.Optional[str] = None 26 | self.expire_in: typing.Optional[arrow.Arrow] = None 27 | 28 | async def _get_access_token(self) -> str: 29 | """Gets Wechatwork API access token. 30 | 31 | Returns cached token if not expired, otherwise, refreshes token. 32 | 33 | :return: Access token. 34 | :rtype: str 35 | """ 36 | # Returns cached token if not expired. 37 | if self.expire_in is not None and arrow.now() < self.expire_in: 38 | return self.access_token 39 | 40 | async with aiohttp.ClientSession() as session: 41 | async with session.get( 42 | 'https://qyapi.weixin.qq.com/cgi-bin/gettoken', 43 | params={ 44 | 'corpid': self.corpid, 45 | 'corpsecret': self.corpsecret, 46 | }, 47 | ) as response: 48 | if not response.ok: 49 | response.raise_for_status() 50 | body = await response.json() 51 | 52 | if body.get('errcode', -1) != 0: 53 | raise ExternalHTTPAPIError(body.get('errmsg', '')) 54 | access_token, expires_in = body.get('access_token', ''), body.get( 55 | 'expires_in', 0 56 | ) 57 | self.access_token = access_token 58 | self.expire_in = arrow.now().shift(seconds=(expires_in - 120)) 59 | return access_token 60 | 61 | def _clean_access_token(self) -> None: 62 | """Cleans up access token to force refreshing token.""" 63 | self.access_token = None 64 | self.expire_in = None 65 | 66 | async def _send(self, *, body: dict[str, typing.Any]) -> None: 67 | """Sends message using Wechatwork API. 68 | 69 | :param body: Request body parameters. 70 | :type body: typing.Dict[str, typing.Any] 71 | """ 72 | req_body = { 73 | 'agentid': self.agentid, 74 | **body, 75 | } 76 | access_token = await self._get_access_token() 77 | async with aiohttp.ClientSession() as session: 78 | async with session.post( 79 | 'https://qyapi.weixin.qq.com/cgi-bin/message/send', 80 | params={'access_token': access_token}, 81 | json=req_body, 82 | ) as response: 83 | if not response.ok: 84 | response.raise_for_status() 85 | body = await response.json() 86 | if body.get('errcode', -1) != 0: 87 | raise ExternalHTTPAPIError(body.get('errmsg', '')) 88 | 89 | async def send_message(self, message: ResponseMessage) -> None: 90 | """Using Wechatwork agent to send message.""" 91 | try: 92 | content, message_type = await render(message) 93 | except NotImplementedError: 94 | content, message_type = await render( 95 | message.downgrade_to_text_message() 96 | ) 97 | body = { 98 | 'msgtype': message_type, 99 | **content, 100 | } 101 | if message.scope == MessageScope.USER: 102 | body['touser'] = ( 103 | message.receiver_id 104 | if isinstance(message.receiver_id, str) 105 | else '|'.join(message.receiver_id) 106 | ) 107 | elif message.scope == MessageScope.CUSTOMIZED_1: 108 | body['toparty'] = ( 109 | message.receiver_id 110 | if isinstance(message.receiver_id, str) is str 111 | else '|'.join(message.receiver_id) 112 | ) 113 | elif message.scope == MessageScope.CUSTOMIZED_2: 114 | body['totag'] = ( 115 | message.receiver_id 116 | if isinstance(message.receiver_id, str) is str 117 | else '|'.join(message.receiver_id) 118 | ) 119 | 120 | await self._send(body=body) 121 | -------------------------------------------------------------------------------- /ailingbot/channels/wechatwork/encrypt.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import base64 4 | import hashlib 5 | import socket 6 | import struct 7 | 8 | from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes 9 | 10 | 11 | def signature( 12 | *, token: str, timestamp: int, nonce: int, msg_encrypt: str 13 | ) -> str: 14 | """Calculates wechatwork message signature. 15 | 16 | :param token: Signature token. 17 | :type token: str 18 | :param timestamp: Signature timestamp. 19 | :type timestamp: int 20 | :param nonce: Signature nonce. 21 | :type nonce: int 22 | :param msg_encrypt: Signature content. 23 | :type msg_encrypt: str 24 | :return: The signature. 25 | :rtype: str 26 | """ 27 | sort_list = [token, str(timestamp), str(nonce), msg_encrypt] 28 | sort_list.sort() 29 | sha = hashlib.sha1() 30 | sha.update(''.join(sort_list).encode()) 31 | return sha.hexdigest() 32 | 33 | 34 | def decrypt(*, key: str, msg_encrypt: str) -> tuple[str, str]: 35 | """Decrypts ciphertext. 36 | 37 | :param key: AES encrypt key. 38 | :type key: str 39 | :param msg_encrypt: Ciphertext. 40 | :type msg_encrypt: str 41 | :return: Message content and receiver id in plaintext. 42 | :rtype: typing.Tuple[str, str] 43 | """ 44 | decoded_key = base64.b64decode(key + '=') 45 | cipher = Cipher(algorithms.AES(decoded_key), modes.CBC(decoded_key[:16])) 46 | decryptor = cipher.decryptor() 47 | plain_text = ( 48 | decryptor.update(base64.b64decode(msg_encrypt)) + decryptor.finalize() 49 | ) 50 | pad = plain_text[-1] 51 | content = plain_text[16:-pad] 52 | len_ = socket.ntohl(struct.unpack('I', content[:4])[0]) 53 | return content[4 : len_ + 4].decode('utf-8'), content[len_ + 4 :].decode( 54 | 'utf-8' 55 | ) 56 | -------------------------------------------------------------------------------- /ailingbot/channels/wechatwork/render.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import functools 4 | 5 | from ailingbot.chat.messages import ( 6 | ResponseMessage, 7 | TextResponseMessage, 8 | FallbackResponseMessage, 9 | ) 10 | 11 | 12 | @functools.singledispatch 13 | async def render(response: ResponseMessage) -> tuple[dict, str]: 14 | """Virtual function of all response message renders. 15 | 16 | Converts response message to Wechatwork content. 17 | 18 | :param response: Response message. 19 | :type response: ResponseMessage 20 | :return: Render result and Wechatwork message type. 21 | :rtype: typing.Tuple[dict, str]: 22 | """ 23 | raise NotImplementedError 24 | 25 | 26 | @render.register 27 | async def _render(response: TextResponseMessage) -> tuple[dict, str]: 28 | """Renders text response message.""" 29 | content = { 30 | 'text': { 31 | 'content': response.text, 32 | } 33 | } 34 | message_type = 'text' 35 | return content, message_type 36 | 37 | 38 | @render.register 39 | async def _render(response: FallbackResponseMessage) -> tuple[dict, str]: 40 | """Renders error fallback response message.""" 41 | content = { 42 | 'markdown': { 43 | 'content': f"""Error occurred 44 | Cause: {response.reason} 45 | Suggestion: {response.suggestion}""" 46 | } 47 | } 48 | message_type = 'markdown' 49 | return content, message_type 50 | -------------------------------------------------------------------------------- /ailingbot/channels/wechatwork/webhook.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing 4 | from urllib import parse 5 | 6 | import xmltodict 7 | from asgiref.typing import ASGIApplication 8 | from fastapi import FastAPI, status, HTTPException, BackgroundTasks 9 | from fastapi.requests import Request 10 | from fastapi.responses import PlainTextResponse 11 | 12 | from ailingbot.channels.channel import ChannelWebhookFactory 13 | from ailingbot.channels.wechatwork.agent import WechatworkAgent 14 | from ailingbot.channels.wechatwork.encrypt import signature, decrypt 15 | from ailingbot.chat.chatbot import ChatBot 16 | from ailingbot.chat.messages import ( 17 | TextRequestMessage, 18 | MessageScope, 19 | RequestMessage, 20 | ) 21 | from ailingbot.config import settings 22 | 23 | 24 | class WechatworkWebhookFactory(ChannelWebhookFactory): 25 | """Factory that creates wechatwork webhook ASGI application.""" 26 | 27 | def __init__(self, debug: bool = False): 28 | super(WechatworkWebhookFactory, self).__init__(debug=debug) 29 | 30 | self.token = settings.channel.token 31 | self.aes_key = settings.channel.aes_key 32 | 33 | self.app: typing.Optional[ASGIApplication | typing.Callable] = None 34 | self.agent: typing.Optional[WechatworkAgent] = None 35 | self.bot: typing.Optional[ChatBot] = None 36 | 37 | async def create_webhook_app(self) -> ASGIApplication | typing.Callable: 38 | self.app = FastAPI() 39 | self.agent = WechatworkAgent() 40 | self.bot = ChatBot(debug=self.debug) 41 | 42 | async def _chat_task( 43 | conversation_id: str, message: RequestMessage 44 | ) -> None: 45 | """Send a request message to the bot, receive a response message, and send it back to the user.""" 46 | response = await self.bot.chat( 47 | conversation_id=conversation_id, message=message 48 | ) 49 | await self.agent.send_message(response) 50 | 51 | @self.app.on_event('startup') 52 | async def startup() -> None: 53 | await self.agent.initialize() 54 | await self.bot.initialize() 55 | 56 | @self.app.on_event('shutdown') 57 | async def shutdown() -> None: 58 | await self.agent.finalize() 59 | await self.bot.finalize() 60 | 61 | @self.app.get( 62 | '/webhook/wechatwork/event/', 63 | status_code=status.HTTP_200_OK, 64 | response_class=PlainTextResponse, 65 | ) 66 | async def handle_challenge( 67 | msg_signature: str, timestamp: int, nonce: int, echostr: str 68 | ) -> str: 69 | """Handle the challenge request from Wechatwork. 70 | 71 | :param msg_signature: Message signature from challenge request. 72 | :type msg_signature: str 73 | :param timestamp: Challenge request timestamp. 74 | :type timestamp: int 75 | :param nonce: Challenge request nonce. 76 | :type nonce: int 77 | :param echostr: Challenge request echostr, which should be decrypted and respond to challenger. 78 | :type echostr: str 79 | :return: Decrypted echostr. 80 | :rtype: str 81 | """ 82 | token = self.token 83 | aes_key = self.aes_key 84 | encrypt_message = parse.unquote(echostr) 85 | 86 | sig = signature( 87 | token=token, 88 | timestamp=timestamp, 89 | nonce=nonce, 90 | msg_encrypt=encrypt_message, 91 | ) 92 | if sig != msg_signature: 93 | raise HTTPException( 94 | status_code=status.HTTP_401_UNAUTHORIZED, 95 | detail='Invalid signature.', 96 | ) 97 | 98 | message, _ = decrypt(key=aes_key, msg_encrypt=encrypt_message) 99 | return message 100 | 101 | @self.app.post( 102 | '/webhook/wechatwork/event/', status_code=status.HTTP_200_OK 103 | ) 104 | async def handle_event( 105 | msg_signature: str, 106 | timestamp: int, 107 | nonce: int, 108 | request: Request, 109 | background_tasks: BackgroundTasks, 110 | ) -> dict: 111 | """Handle the event request from Wechatwork. 112 | 113 | :param msg_signature: Message signature from challenge request. 114 | :type msg_signature: str 115 | :param timestamp: Challenge request timestamp. 116 | :type timestamp: int 117 | :param nonce: Challenge request nonce. 118 | :type nonce: int 119 | :param request: Http request. 120 | :type request: Request 121 | :param background_tasks: 122 | :type background_tasks: 123 | :return: Empty dict. 124 | :rtype: dict 125 | """ 126 | token = self.token 127 | aes_key = self.aes_key 128 | body_xml = await request.body() 129 | body = xmltodict.parse(body_xml) 130 | encrypt_message = body.get('xml', {}).get('Encrypt', '') 131 | 132 | sig = signature( 133 | token=token, 134 | timestamp=timestamp, 135 | nonce=nonce, 136 | msg_encrypt=encrypt_message, 137 | ) 138 | if sig != msg_signature: 139 | raise HTTPException( 140 | status_code=status.HTTP_401_UNAUTHORIZED, 141 | detail='Invalid signature.', 142 | ) 143 | 144 | message_xml, _ = decrypt(key=aes_key, msg_encrypt=encrypt_message) 145 | message = xmltodict.parse(message_xml) 146 | from_user_name = message.get('xml', {}).get('FromUserName', '') 147 | msg_type = message.get('xml', {}).get('MsgType', '') 148 | 149 | req_msg = None 150 | if msg_type == 'text': 151 | message_id = message.get('xml', {}).get('MsgId', '') 152 | content = message.get('xml', {}).get('Content', '') 153 | req_msg = TextRequestMessage( 154 | uuid=message_id, 155 | sender_id=from_user_name, 156 | scope=MessageScope.USER, 157 | text=content, 158 | ) 159 | 160 | if req_msg: 161 | background_tasks.add_task( 162 | _chat_task, req_msg.sender_id, req_msg 163 | ) 164 | 165 | return {} 166 | 167 | return self.app 168 | -------------------------------------------------------------------------------- /ailingbot/chat/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ericzhang-cn/ailingbot/dd9962b021b2abb0995b0f0620c9cb24033ee0cd/ailingbot/chat/__init__.py -------------------------------------------------------------------------------- /ailingbot/chat/chatbot.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import typing 5 | import uuid 6 | 7 | from loguru import logger 8 | 9 | from ailingbot.chat.messages import ( 10 | RequestMessage, 11 | FallbackResponseMessage, 12 | ResponseMessage, 13 | ) 14 | from ailingbot.chat.policy import ChatPolicy 15 | from ailingbot.config import settings 16 | from ailingbot.shared.abc import AbstractAsyncComponent 17 | 18 | 19 | class ChatBot(AbstractAsyncComponent): 20 | """ChatBot is core component that responsible for retrieve request and make response.""" 21 | 22 | def __init__( 23 | self, 24 | *, 25 | debug: bool = False, 26 | ): 27 | super(ChatBot, self).__init__() 28 | 29 | self.debug = debug 30 | 31 | self.locks: dict[str, asyncio.Lock] = {} 32 | self.policy: typing.Optional[ChatPolicy] = None 33 | 34 | async def chat( 35 | self, *, conversation_id: str, message: RequestMessage 36 | ) -> ResponseMessage: 37 | """Run chat pipeline, and replies messages to sender. 38 | 39 | :param conversation_id: Conversation id. 40 | :type conversation_id: str 41 | :param message: Reqeust message. 42 | :type message: RequestMessage 43 | """ 44 | if conversation_id not in self.locks: 45 | self.locks[conversation_id] = asyncio.Lock() 46 | lock = self.locks[conversation_id] 47 | 48 | async with lock: 49 | try: 50 | r = await self.policy.respond( 51 | conversation_id=conversation_id, message=message 52 | ) 53 | except Exception as e: 54 | logger.error(e) 55 | r = FallbackResponseMessage( 56 | reason=str(e), 57 | ) 58 | r.uuid = str(uuid.uuid4()) 59 | r.ack_uuid = message.uuid 60 | r.receiver_id = message.sender_id 61 | r.scope = message.scope 62 | r.echo = message.echo 63 | 64 | return r 65 | 66 | async def _initialize(self) -> None: 67 | self.policy = ChatPolicy.get_policy( 68 | name=settings.policy.name, 69 | debug=self.debug, 70 | ) 71 | await self.policy.initialize() 72 | 73 | async def _finalize(self): 74 | await self.policy.finalize() 75 | -------------------------------------------------------------------------------- /ailingbot/chat/messages.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import abc 4 | import enum 5 | import typing 6 | 7 | from pydantic import BaseModel 8 | 9 | 10 | class MessageScope(str, enum.Enum): 11 | """The scope of message sender or receiver.""" 12 | 13 | USER = 'user' 14 | GROUP = 'group' 15 | CUSTOMIZED_1 = 'customized_1' 16 | CUSTOMIZED_2 = 'customized_2' 17 | CUSTOMIZED_3 = 'customized_3' 18 | CUSTOMIZED_4 = 'customized_4' 19 | CUSTOMIZED_5 = 'customized_5' 20 | 21 | 22 | class RequestMessage(BaseModel, abc.ABC): 23 | """Base class of request messages.""" 24 | 25 | uuid: str = '' 26 | sender_id: str = '' 27 | scope: typing.Optional[MessageScope] = None 28 | meta: dict = {} 29 | echo: dict = {} 30 | 31 | 32 | class TextRequestMessage(RequestMessage): 33 | """Plain text request message.""" 34 | 35 | text: str = '' 36 | 37 | 38 | class FileRequestMessage(RequestMessage): 39 | """File request message.""" 40 | 41 | content: bytes 42 | file_type: str 43 | file_name: str 44 | 45 | 46 | class ResponseMessage(BaseModel, abc.ABC): 47 | """Base class of response messages.""" 48 | 49 | uuid: str = '' 50 | ack_uuid: str = '' 51 | receiver_id: typing.Union[str, list[str]] = '' 52 | scope: typing.Optional[MessageScope] = None 53 | meta: dict = {} 54 | echo: dict = {} 55 | 56 | def _downgrade(self) -> str: 57 | """Default downgrade method: use the JSON representation.""" 58 | return self.json(ensure_ascii=False) 59 | 60 | def downgrade_to_text_message(self) -> TextResponseMessage: 61 | """When the channel does not support rendering this type of message, how to downgrade it to a text message.""" 62 | return TextResponseMessage( 63 | uuid=self.uuid, 64 | ack_uuid=self.ack_uuid, 65 | receiver_id=self.receiver_id, 66 | scope=self.scope, 67 | meta=self.meta, 68 | echo=self.echo, 69 | text=self._downgrade(), 70 | ) 71 | 72 | 73 | class SilenceResponseMessage(ResponseMessage): 74 | """Response message that outputs nothing.""" 75 | 76 | def _downgrade(self) -> str: 77 | return '' 78 | 79 | 80 | class TextResponseMessage(ResponseMessage): 81 | """Plain text response message.""" 82 | 83 | text: str = '' 84 | 85 | def _downgrade(self) -> str: 86 | return self.text 87 | 88 | 89 | class FallbackResponseMessage(ResponseMessage): 90 | """Fallback response message. 91 | 92 | Send this message when error occurred. 93 | """ 94 | 95 | reason: str = '' 96 | suggestion: str = '' 97 | 98 | def _downgrade(self) -> str: 99 | return f"""{self.reason} 100 | {self.suggestion}""" 101 | -------------------------------------------------------------------------------- /ailingbot/chat/policies/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ericzhang-cn/ailingbot/dd9962b021b2abb0995b0f0620c9cb24033ee0cd/ailingbot/chat/policies/__init__.py -------------------------------------------------------------------------------- /ailingbot/chat/policies/conversation.py: -------------------------------------------------------------------------------- 1 | import copy 2 | 3 | from langchain import ConversationChain 4 | from langchain.llms.loading import load_llm_from_config 5 | from langchain.memory import ConversationBufferWindowMemory 6 | from langchain.memory.chat_memory import BaseChatMemory 7 | 8 | from ailingbot.chat.messages import ( 9 | ResponseMessage, 10 | TextRequestMessage, 11 | FallbackResponseMessage, 12 | TextResponseMessage, 13 | RequestMessage, 14 | ) 15 | from ailingbot.chat.policy import ChatPolicy 16 | from ailingbot.config import settings 17 | 18 | 19 | class ConversationChatPolicy(ChatPolicy): 20 | """Having a direct conversation with a large language model.""" 21 | 22 | def __init__( 23 | self, 24 | *, 25 | debug: bool = False, 26 | ): 27 | super().__init__( 28 | debug=debug, 29 | ) 30 | 31 | llm_config = copy.deepcopy(settings.policy.llm) 32 | llm = load_llm_from_config(llm_config) 33 | self.chain = ConversationChain(llm=llm, verbose=debug) 34 | self.history_size = settings.policy.get('history_size', 5) 35 | self.memories: dict[str, BaseChatMemory] = {} 36 | 37 | async def _load_memory(self, *, conversation_id: str) -> BaseChatMemory: 38 | """Load memory for conversation. Create a new memory if not exists. 39 | 40 | :param conversation_id: Conversation ID. 41 | :type conversation_id: str 42 | :return: Chat memory. 43 | :rtype: BaseChatMemory 44 | """ 45 | if conversation_id not in self.memories: 46 | self.memories[conversation_id] = ConversationBufferWindowMemory( 47 | k=self.history_size 48 | ) 49 | return self.memories[conversation_id] 50 | 51 | async def respond( 52 | self, *, conversation_id: str, message: RequestMessage 53 | ) -> ResponseMessage: 54 | if not isinstance(message, TextRequestMessage): 55 | response = FallbackResponseMessage() 56 | response.reason = '不支持的消息类型' 57 | else: 58 | self.chain.memory = await self._load_memory( 59 | conversation_id=conversation_id 60 | ) 61 | response = TextResponseMessage() 62 | response.text = await self.chain.arun(message.text) 63 | 64 | return response 65 | -------------------------------------------------------------------------------- /ailingbot/chat/policies/document_qa.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import tempfile 3 | 4 | from langchain.chains import RetrievalQA 5 | from langchain.chains.base import Chain 6 | from langchain.document_loaders import PyPDFLoader 7 | from langchain.embeddings import OpenAIEmbeddings 8 | from langchain.llms.loading import load_llm_from_config 9 | from langchain.text_splitter import CharacterTextSplitter 10 | from langchain.vectorstores import Chroma 11 | from langchain.vectorstores.base import VectorStoreRetriever 12 | 13 | from ailingbot.chat.messages import ( 14 | RequestMessage, 15 | ResponseMessage, 16 | TextRequestMessage, 17 | FallbackResponseMessage, 18 | TextResponseMessage, 19 | FileRequestMessage, 20 | ) 21 | from ailingbot.chat.policy import ChatPolicy 22 | from ailingbot.config import settings 23 | from ailingbot.shared.errors import ChatPolicyError 24 | 25 | 26 | class DocumentQAPolicy(ChatPolicy): 27 | """Question-Answering based on documents.""" 28 | 29 | def __init__( 30 | self, 31 | *, 32 | debug: bool = False, 33 | ): 34 | super().__init__( 35 | debug=debug, 36 | ) 37 | 38 | llm_config = copy.deepcopy(settings.policy.llm) 39 | self.llm = load_llm_from_config(llm_config) 40 | self.chains: dict[str, Chain] = {} 41 | self.chunk_size = settings.policy.get('chunk_size', 1000) 42 | self.chunk_overlap = settings.policy.get('chunk_overlap', 0) 43 | 44 | def _build_documents_index( 45 | self, *, content: bytes, file_type: str 46 | ) -> VectorStoreRetriever: 47 | """Load document and build index.""" 48 | if file_type.lower() != 'pdf': 49 | raise ChatPolicyError( 50 | reason='目前只支持PDF文档', 51 | suggestion='请上传PDF文档', 52 | ) 53 | 54 | with tempfile.NamedTemporaryFile(delete=True) as tf: 55 | tf.write(content) 56 | loader = PyPDFLoader(file_path=tf.name) 57 | documents = loader.load() 58 | 59 | text_splitter = CharacterTextSplitter( 60 | chunk_size=self.chunk_size, chunk_overlap=self.chunk_overlap 61 | ) 62 | texts = text_splitter.split_documents(documents) 63 | 64 | embeddings = OpenAIEmbeddings( 65 | openai_api_key=settings.policy.llm.openai_api_key, 66 | ) 67 | docsearch = Chroma.from_documents(texts, embeddings) 68 | 69 | return docsearch.as_retriever() 70 | 71 | async def respond( 72 | self, *, conversation_id: str, message: RequestMessage 73 | ) -> ResponseMessage: 74 | if isinstance(message, TextRequestMessage): 75 | if conversation_id not in self.chains: 76 | response = FallbackResponseMessage() 77 | response.reason = '还没有上传文档' 78 | response.suggestion = '请先上传文档' 79 | else: 80 | response = TextResponseMessage() 81 | response.text = await self.chains[conversation_id].arun( 82 | message.text 83 | ) 84 | elif isinstance(message, FileRequestMessage): 85 | self.chains[conversation_id] = RetrievalQA.from_chain_type( 86 | llm=self.llm, 87 | chain_type='stuff', 88 | retriever=self._build_documents_index( 89 | content=message.content, file_type=message.file_type 90 | ), 91 | return_source_documents=False, 92 | verbose=self.debug, 93 | ) 94 | response = TextResponseMessage() 95 | response.text = f'我已完成学习,现在可以针对 {message.file_name} 进行提问了' 96 | else: 97 | response = FallbackResponseMessage() 98 | response.reason = '不支持的消息类型' 99 | 100 | return response 101 | -------------------------------------------------------------------------------- /ailingbot/chat/policy.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import abc 4 | 5 | from ailingbot.chat.messages import RequestMessage, ResponseMessage 6 | from ailingbot.shared.abc import AbstractAsyncComponent 7 | from ailingbot.shared.misc import get_class_dynamically 8 | 9 | 10 | class ChatPolicy(AbstractAsyncComponent, abc.ABC): 11 | """Base class of chat policies.""" 12 | 13 | def __init__(self, *, debug: bool = False): 14 | super(ChatPolicy, self).__init__() 15 | 16 | self.debug = debug 17 | 18 | @abc.abstractmethod 19 | async def respond( 20 | self, *, conversation_id: str, message: RequestMessage 21 | ) -> ResponseMessage: 22 | """Responding to user inputs. 23 | 24 | :param conversation_id: 25 | :type conversation_id: 26 | :param message: Request message. 27 | :type message: RequestMessage 28 | """ 29 | raise NotImplementedError 30 | 31 | @staticmethod 32 | def get_policy(name: str, *, debug: bool = False) -> ChatPolicy: 33 | """Gets policy instance. 34 | 35 | :param name: Built-in policy name or full path of policy class. 36 | :type name: str 37 | :param debug: 38 | :type debug: 39 | :return: Policy instance. 40 | :rtype: ChatPolicy 41 | """ 42 | if name.lower() == 'conversation': 43 | from ailingbot.chat.policies.conversation import ( 44 | ConversationChatPolicy, 45 | ) 46 | 47 | instance = ConversationChatPolicy(debug=debug) 48 | elif name.lower() == 'document_qa': 49 | from ailingbot.chat.policies.document_qa import ( 50 | DocumentQAPolicy, 51 | ) 52 | 53 | instance = DocumentQAPolicy(debug=debug) 54 | else: 55 | instance = get_class_dynamically(name)(debug=debug) 56 | 57 | return instance 58 | -------------------------------------------------------------------------------- /ailingbot/cli/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ericzhang-cn/ailingbot/dd9962b021b2abb0995b0f0620c9cb24033ee0cd/ailingbot/cli/__init__.py -------------------------------------------------------------------------------- /ailingbot/cli/cli.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import os.path 5 | import sys 6 | import typing 7 | import uuid 8 | from functools import wraps 9 | 10 | import click 11 | import emoji 12 | import tomlkit 13 | import uvicorn 14 | from loguru import logger 15 | from prompt_toolkit import PromptSession 16 | from prompt_toolkit.formatted_text import FormattedText 17 | from rich.console import Console 18 | 19 | import ailingbot.shared.errors 20 | from ailingbot import endpoint 21 | from ailingbot.channels.channel import ChannelWebhookFactory 22 | from ailingbot.chat.chatbot import ChatBot 23 | from ailingbot.chat.messages import ( 24 | TextRequestMessage, 25 | FallbackResponseMessage, 26 | MessageScope, 27 | ) 28 | from ailingbot.cli import options 29 | from ailingbot.cli.render import render, display_radio_prompt 30 | from ailingbot.config import settings 31 | 32 | 33 | def _coro_cmd(f: typing.Callable) -> typing.Callable: 34 | """Decorator that wraps an async command to run with asyncio. 35 | 36 | :param f: Async function. 37 | :type f: typing.Callable 38 | :return: Wrapped function. 39 | :rtype: typing.Callable 40 | """ 41 | 42 | @wraps(f) 43 | def wrapper(*args, **kwargs): 44 | return asyncio.run(f(*args, **kwargs)) 45 | 46 | return wrapper 47 | 48 | 49 | def _set_logger(*, sink: str, level: str) -> None: 50 | """Set logger sink and level. 51 | 52 | :param sink: 53 | :type sink: 54 | :param level: 55 | :type level: 56 | :return: 57 | :rtype: 58 | """ 59 | logger.remove() 60 | 61 | if sink.lower() == 'stderr': 62 | logger.add(sys.stderr, level=level.upper()) 63 | elif sink.lower() == 'stdout': 64 | logger.add(sys.stdout, level=level.upper()) 65 | else: 66 | logger.add(sink, level=level.upper()) 67 | 68 | 69 | @click.group(name='cli', help='AilingBot command line tools.') 70 | def command_line_tools(): 71 | """AilingBot command line tools.""" 72 | pass 73 | 74 | 75 | @command_line_tools.command( 76 | name='chat', help='Start an interactive bot conversation environment.' 77 | ) 78 | @click.option('--debug', is_flag=True, help='Enable debug mode.') 79 | @_coro_cmd 80 | async def chat( 81 | debug: bool, 82 | ): 83 | """Start an interactive bot conversation environment. 84 | 85 | :param debug: Whether to enable debug mode. 86 | :type debug: bool 87 | """ 88 | chatbot = ChatBot( 89 | debug=debug, 90 | ) 91 | try: 92 | await chatbot.initialize() 93 | except ailingbot.shared.errors.AilingBotError as e: 94 | raise click.ClickException(e.reason) 95 | 96 | conversation_id = str(uuid.uuid4()) 97 | 98 | click.echo( 99 | click.style( 100 | text='Start a conversation with bot, type `exit` to quit', 101 | fg='blue', 102 | ) 103 | ) 104 | click.echo( 105 | click.style( 106 | text=f'{emoji.emojize(":light_bulb:")} Policy: {settings.policy.name}', 107 | fg='cyan', 108 | ) 109 | ) 110 | 111 | session = PromptSession() 112 | request = None 113 | while True: 114 | # Default path: no more request to send, start a new conversation round. 115 | if request is None: 116 | input_ = await session.prompt_async( 117 | FormattedText([('skyblue', '> Input: ')]) 118 | ) 119 | if input_ == '': 120 | continue 121 | if input_ == 'exit': 122 | break 123 | request = TextRequestMessage( 124 | text=input_, 125 | scope=MessageScope.USER, 126 | ) 127 | 128 | # Sends request and processes different types response. 129 | try: 130 | response = await chatbot.chat( 131 | conversation_id=conversation_id, message=request 132 | ) 133 | request = None 134 | await render(response) 135 | except ailingbot.shared.errors.AilingBotError as e: 136 | if e.critical: 137 | raise click.exceptions.ClickException(e.reason) 138 | else: 139 | request = None 140 | 141 | response = FallbackResponseMessage( 142 | reason=e.reason, suggestion=e.suggestion 143 | ) 144 | await render(response) 145 | 146 | 147 | @command_line_tools.command( 148 | name='serve', help='Run webhook server to receive events.' 149 | ) 150 | @options.log_level_option 151 | @options.log_file_option 152 | @click.option('--debug', is_flag=True, help='Enable debug mode.') 153 | @_coro_cmd 154 | async def serve( 155 | log_level: str, 156 | log_file: str, 157 | debug: bool, 158 | ): 159 | _set_logger(sink=log_file, level=log_level) 160 | 161 | webhook = await ChannelWebhookFactory.get_webhook( 162 | settings.channel.name, 163 | settings.channel.get('webhook_name', None), 164 | debug=debug, 165 | ) 166 | 167 | config = uvicorn.Config(app=webhook, **settings.uvicorn) 168 | server = uvicorn.Server(config) 169 | await server.serve() 170 | 171 | 172 | @command_line_tools.command( 173 | name='config', help='Show current configuration information.' 174 | ) 175 | @click.option( 176 | '-k', 177 | '--config-key', 178 | type=click.STRING, 179 | help='Configuration key.', 180 | ) 181 | def config_show( 182 | config_key: str, 183 | ): 184 | console = Console() 185 | if config_key is None: 186 | console.print(settings.as_dict()) 187 | else: 188 | try: 189 | console.print(settings[config_key].to_dict()) 190 | except AttributeError: 191 | console.print(settings[config_key]) 192 | except KeyError: 193 | console.print(None) 194 | 195 | 196 | @command_line_tools.command( 197 | name='init', help='Initialize the AilingBot environment.' 198 | ) 199 | @click.option('--silence', is_flag=True, help='Without asking the user.') 200 | @click.option( 201 | '--overwrite', 202 | is_flag=True, 203 | help='Overwrite existing file if a file with the same name already exists.', 204 | ) 205 | @_coro_cmd 206 | async def init(silence: bool, overwrite: bool): 207 | """Initialize the AilingBot environment.""" 208 | file_path = os.path.join('.', 'settings.toml') 209 | if not overwrite: 210 | if os.path.exists(file_path): 211 | click.echo( 212 | click.style( 213 | text=f'Configuration file {file_path} already exists.', 214 | fg='yellow', 215 | ) 216 | ) 217 | raise click.Abort() 218 | 219 | config: dict = { 220 | 'lang': 'zh_CN', 221 | 'tz': 'Asia/Shanghai', 222 | 'policy': {}, 223 | 'channel': {}, 224 | 'uvicorn': { 225 | 'host': '0.0.0.0', 226 | 'port': 8080, 227 | }, 228 | } 229 | if silence: 230 | config['policy'] = { 231 | 'name': 'conversation', 232 | 'history_size': 5, 233 | 'llm': { 234 | '_type': 'openai', 235 | 'model_name': 'gpt-3.5-turbo', 236 | 'openai_api_key': '', 237 | 'temperature': 0, 238 | }, 239 | } 240 | config['channel'] = { 241 | 'name': 'wechatwork', 242 | 'corpid': '', 243 | 'corpsecret': '', 244 | 'agentid': 0, 245 | 'token': '', 246 | 'aes_key': '', 247 | } 248 | else: 249 | policy = await display_radio_prompt( 250 | title='Select chat policy:', 251 | values=[ 252 | (x, x) 253 | for x in [ 254 | 'conversation', 255 | 'document_qa', 256 | 'Configure Later', 257 | ] 258 | ], 259 | cancel_value='Configure Later', 260 | ) 261 | if policy == 'conversation': 262 | config['policy'] = { 263 | 'name': 'conversation', 264 | 'history_size': 5, 265 | 'llm': { 266 | '_type': 'openai', 267 | 'model_name': 'gpt-3.5-turbo-16k', 268 | 'openai_api_key': '', 269 | 'temperature': 0, 270 | }, 271 | } 272 | elif policy == 'document_qa': 273 | config['policy'] = { 274 | 'name': 'document_qa', 275 | 'chunk_size': 1000, 276 | 'chunk_overlap': 0, 277 | 'llm': { 278 | '_type': 'openai', 279 | 'model_name': 'gpt-3.5-turbo-16k', 280 | 'openai_api_key': '', 281 | 'temperature': 0, 282 | }, 283 | } 284 | 285 | channel = await display_radio_prompt( 286 | title='Select channel:', 287 | values=[ 288 | (x, x) for x in ['wechatwork', 'feishu', 'Configure Later'] 289 | ], 290 | cancel_value='Configure Later', 291 | ) 292 | if channel == 'wechatwork': 293 | config['channel'] = { 294 | 'name': 'wechatwork', 295 | 'corpid': '', 296 | 'corpsecret': '', 297 | 'agentid': 0, 298 | 'token': '', 299 | 'aes_key': '', 300 | } 301 | elif channel == 'feishu': 302 | config['channel'] = { 303 | 'name': 'feishu', 304 | 'app_id': '', 305 | 'app_secret': '', 306 | 'verification_token': 0, 307 | } 308 | elif channel == 'dingtalk': 309 | config['channel'] = { 310 | 'name': 'dingtalk', 311 | 'app_key': '', 312 | 'app_secret': '', 313 | 'robot_code': '', 314 | } 315 | 316 | with open(file_path, 'w') as f: 317 | f.write(tomlkit.dumps(config)) 318 | click.echo( 319 | click.style( 320 | text=f'Configuration file {file_path} has been created.', 321 | fg='green', 322 | ) 323 | ) 324 | 325 | 326 | @command_line_tools.command(name='api', help='Run endpoint server.') 327 | @options.log_level_option 328 | @options.log_file_option 329 | @_coro_cmd 330 | async def serve( 331 | log_level: str, 332 | log_file: str, 333 | ): 334 | _set_logger(sink=log_file, level=log_level) 335 | 336 | config = uvicorn.Config( 337 | app='ailingbot.endpoint.server:app', **settings.uvicorn 338 | ) 339 | server = uvicorn.Server(config) 340 | await server.serve() 341 | 342 | 343 | if __name__ == '__main__': 344 | command_line_tools(prog_name='ailingbot') 345 | -------------------------------------------------------------------------------- /ailingbot/cli/options.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | import click 4 | import tomli 5 | 6 | 7 | class TableParamType(click.ParamType): 8 | """Represents the type of table parameter, using TOML table format: {key=value,key=value,...,key=value}""" 9 | 10 | name = 'table' 11 | 12 | def convert( 13 | self, 14 | value: typing.Any, 15 | param: typing.Optional[click.Parameter], 16 | ctx: typing.Optional[click.Context], 17 | ) -> typing.Any: 18 | if value is None: 19 | return None 20 | if type(value) is dict: 21 | return value 22 | if type(value) is not str: 23 | raise ValueError('Type of value should be `str`.') 24 | 25 | value = f'value={value}' 26 | 27 | try: 28 | dict_ = tomli.loads(value) 29 | return dict_['value'] 30 | except tomli.TOMLDecodeError: 31 | raise click.BadParameter('Value is not a valid TOML table value.') 32 | 33 | def __repr__(self) -> str: 34 | return 'Table' 35 | 36 | 37 | env_var_prefix = 'AILINGBOT' 38 | 39 | log_level_option = click.option( 40 | '--log-level', 41 | type=click.Choice( 42 | choices=[ 43 | 'TRACE', 44 | 'DEBUG', 45 | 'INFO', 46 | 'SUCCESS', 47 | 'WARNING', 48 | 'ERROR', 49 | 'CRITICAL', 50 | ], 51 | case_sensitive=False, 52 | ), 53 | help=f'The minimum severity level from which logged messages should be sent to(read from environment variable {env_var_prefix}_LOG_LEVEL if is not passed into).', 54 | envvar=f'{env_var_prefix}_LOG__LEVEL', 55 | default='TRACE', 56 | show_default=True, 57 | ) 58 | 59 | log_file_option = click.option( 60 | '--log-file', 61 | type=click.STRING, 62 | help=f'STDOUT, STDERR, or file path(read from environment variable {env_var_prefix}_LOG_FILE if is not passed into).', 63 | envvar=f'{env_var_prefix}_LOG__FILE', 64 | default='STDERR', 65 | show_default=True, 66 | ) 67 | -------------------------------------------------------------------------------- /ailingbot/cli/render.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import functools 4 | import typing 5 | 6 | import click 7 | from prompt_toolkit import Application 8 | from prompt_toolkit.formatted_text import AnyFormattedText, FormattedText 9 | from prompt_toolkit.key_binding import KeyBindings, merge_key_bindings 10 | from prompt_toolkit.key_binding.defaults import load_key_bindings 11 | from prompt_toolkit.layout import Layout, HSplit 12 | from prompt_toolkit.widgets import RadioList, Label, TextArea 13 | 14 | from ailingbot.chat.messages import ( 15 | ResponseMessage, 16 | TextResponseMessage, 17 | FallbackResponseMessage, 18 | SilenceResponseMessage, 19 | ) 20 | 21 | 22 | async def display_input_prompt( 23 | *, 24 | title: str = '', 25 | visible: bool = True, 26 | required: bool = True, 27 | cancel_value: typing.Any = None, 28 | ) -> typing.Optional[str]: 29 | text_area = TextArea(password=not visible) 30 | 31 | bindings = KeyBindings() 32 | 33 | @bindings.add('enter') 34 | def exit_with_value(event): 35 | """Pressing Enter will exit the user interface, returning the highlighted value.""" 36 | event.app.exit(result=text_area.text) 37 | 38 | @bindings.add('c-c') 39 | def backup_exit_with_value(event): 40 | """Pressing Ctrl-C will exit the user interface with the cancel_value.""" 41 | event.app.exit(result=cancel_value) 42 | 43 | if required: 44 | title_ = f'{title} (Required)' 45 | else: 46 | title_ = f'{title} (Optional, press Enter to skip)' 47 | 48 | application = Application( 49 | layout=Layout(HSplit([Label(title_), text_area])), 50 | key_bindings=merge_key_bindings([load_key_bindings(), bindings]), 51 | mouse_support=True, 52 | full_screen=False, 53 | ) 54 | 55 | return await application.run_async() 56 | 57 | 58 | async def display_radio_prompt( 59 | *, 60 | title: str = '', 61 | values: typing.Optional[list[tuple[typing.Any, AnyFormattedText]]] = None, 62 | cancel_value: typing.Any = None, 63 | ) -> typing.Any: 64 | """Displays radio boxes for users to select. 65 | 66 | :param title: Radio title. 67 | :type title: str 68 | :param values: Radio values. 69 | :type values: typing.Optional[typing.List[typing.Tuple[str, AnyFormattedText]]] 70 | :param cancel_value: Value that returns when Pressing Ctrl-C. 71 | :type cancel_value: str 72 | :return: Selected value. 73 | :rtype: str 74 | """ 75 | radio_list = RadioList(values) 76 | radio_list.control.key_bindings.remove('enter') 77 | 78 | bindings = KeyBindings() 79 | 80 | @bindings.add('enter') 81 | def exit_with_value(event): 82 | """Pressing Enter will exit the user interface, returning the highlighted value.""" 83 | radio_list._handle_enter() 84 | event.app.exit(result=radio_list.current_value) 85 | 86 | @bindings.add('c-c') 87 | def backup_exit_with_value(event): 88 | """Pressing Ctrl-C will exit the user interface with the cancel_value.""" 89 | event.app.exit(result=cancel_value) 90 | 91 | application = Application( 92 | layout=Layout( 93 | HSplit([Label(FormattedText([('skyblue', title)])), radio_list]) 94 | ), 95 | key_bindings=merge_key_bindings([load_key_bindings(), bindings]), 96 | mouse_support=True, 97 | full_screen=False, 98 | ) 99 | 100 | return await application.run_async() 101 | 102 | 103 | @functools.singledispatch 104 | async def render(response: ResponseMessage) -> None: 105 | """Virtual function of all response message renders. 106 | 107 | Converts response message to command tools content. 108 | 109 | :param response: Response message. 110 | :type response: ResponseMessage 111 | :return: Render result. 112 | :rtype: typing.List[Line]: 113 | """ 114 | raise NotImplementedError() 115 | 116 | 117 | @render.register 118 | async def _render(response: SilenceResponseMessage) -> None: 119 | """Renders silence response message.""" 120 | pass 121 | 122 | 123 | @render.register 124 | async def _render(response: TextResponseMessage) -> None: 125 | """Renders text response message.""" 126 | click.secho(response.text) 127 | 128 | 129 | @render.register 130 | async def _render(response: FallbackResponseMessage) -> None: 131 | """Renders error fallback response message.""" 132 | click.secho('Error occurred', fg='red') 133 | click.secho('----------', fg='red') 134 | click.secho(f'Cause: {response.reason}', italic=True) 135 | click.secho(f'Suggestion: {response.suggestion}', italic=True) 136 | -------------------------------------------------------------------------------- /ailingbot/config.py: -------------------------------------------------------------------------------- 1 | from dynaconf import Dynaconf, Validator 2 | 3 | validators = { 4 | 'broker.name': Validator( 5 | 'broker.name', 6 | must_exist=True, 7 | condition=lambda v: isinstance(v, str), 8 | ), 9 | 'broker.args': Validator( 10 | 'broker.args', 11 | must_exist=True, 12 | condition=lambda v: isinstance(v, dict), 13 | ), 14 | 'channel.agent.name': Validator( 15 | 'channel.agent.name', 16 | must_exist=True, 17 | condition=lambda v: isinstance(v, str), 18 | ), 19 | 'channel.agent.args': Validator( 20 | 'channel.agent.args', 21 | must_exist=True, 22 | condition=lambda v: isinstance(v, dict), 23 | ), 24 | 'channel.webhook.name': Validator( 25 | 'channel.webhook.name', 26 | must_exist=True, 27 | condition=lambda v: isinstance(v, str), 28 | ), 29 | 'channel.webhook.args': Validator( 30 | 'channel.webhook.args', 31 | must_exist=True, 32 | condition=lambda v: isinstance(v, dict), 33 | ), 34 | 'channel.uvicorn.args': Validator( 35 | 'channel.uvicorn.args', 36 | must_exist=True, 37 | condition=lambda v: isinstance(v, dict), 38 | ), 39 | 'policy.name': Validator( 40 | 'policy.name', 41 | must_exist=True, 42 | condition=lambda v: isinstance(v, str), 43 | ), 44 | 'policy.args': Validator( 45 | 'policy.args', 46 | must_exist=True, 47 | condition=lambda v: isinstance(v, dict), 48 | ), 49 | } 50 | 51 | # Settings entrypoint, loads settings from environment variables. 52 | settings = Dynaconf( 53 | envvar_prefix='AILINGBOT', 54 | load_dotenv=True, 55 | settings_files=[ 56 | 'settings.toml', 57 | ], 58 | ) 59 | -------------------------------------------------------------------------------- /ailingbot/endpoint/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ericzhang-cn/ailingbot/dd9962b021b2abb0995b0f0620c9cb24033ee0cd/ailingbot/endpoint/__init__.py -------------------------------------------------------------------------------- /ailingbot/endpoint/model.py: -------------------------------------------------------------------------------- 1 | import enum 2 | import typing 3 | 4 | from pydantic import BaseModel 5 | 6 | 7 | class RequestMessageType(enum.Enum): 8 | TEXT = 'text' 9 | FILE = 'file' 10 | 11 | 12 | class ResponseMessageType(enum.Enum): 13 | TEXT = 'text' 14 | FALLBACK = 'fallback' 15 | 16 | 17 | class ChatRequest(BaseModel): 18 | type: str = 'text' 19 | conversation_id: str = '' 20 | uuid: str = '' 21 | sender_id: str = '' 22 | scope: str = 'user' 23 | meta: dict = {} 24 | echo: dict = {} 25 | text: typing.Optional[str] = None 26 | file_type: typing.Optional[str] = None 27 | file_name: typing.Optional[str] = None 28 | file_url: typing.Optional[str] = None 29 | 30 | 31 | class ChatResponse(BaseModel): 32 | type: str = 'text' 33 | conversation_id: str = '' 34 | uuid: str = '' 35 | ack_uuid: str = '' 36 | receiver_id: str = '' 37 | scope: str = 'user' 38 | meta: dict = {} 39 | echo: dict = {} 40 | text: typing.Optional[str] = None 41 | reason: typing.Optional[str] = None 42 | suggestion: typing.Optional[str] = None 43 | -------------------------------------------------------------------------------- /ailingbot/endpoint/server.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | import aiohttp 4 | from aiohttp import ClientResponseError, ClientError 5 | from fastapi import FastAPI, HTTPException 6 | from starlette import status 7 | 8 | from ailingbot.chat.chatbot import ChatBot 9 | from ailingbot.chat.messages import ( 10 | MessageScope, 11 | TextRequestMessage, 12 | TextResponseMessage, 13 | FileRequestMessage, 14 | FallbackResponseMessage, 15 | ) 16 | from ailingbot.endpoint.model import ( 17 | RequestMessageType, 18 | ResponseMessageType, 19 | ChatRequest, 20 | ChatResponse, 21 | ) 22 | 23 | bot = ChatBot() 24 | app = FastAPI(title='AilingBot') 25 | 26 | 27 | @app.on_event('startup') 28 | async def startup() -> None: 29 | await bot.initialize() 30 | 31 | 32 | @app.on_event('shutdown') 33 | async def shutdown() -> None: 34 | await bot.finalize() 35 | 36 | 37 | @app.post( 38 | '/chat/', 39 | status_code=status.HTTP_200_OK, 40 | response_model=ChatResponse, 41 | tags=['Chat'], 42 | ) 43 | async def chat(request: ChatRequest) -> ChatResponse: 44 | try: 45 | req_type = RequestMessageType(request.type.lower()) 46 | except ValueError: 47 | raise HTTPException( 48 | status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, 49 | detail=f'Request message type {request.type} is not supported.', 50 | ) 51 | 52 | _uuid = request.uuid or str(uuid.uuid4()) 53 | conversation_id = request.uuid or 'default_conversation' 54 | sender_id = request.sender_id or 'anonymous' 55 | try: 56 | scope = MessageScope(request.scope.lower()) 57 | except ValueError: 58 | raise HTTPException( 59 | status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, 60 | detail=f'Request message scope {request.scope} is not supported.', 61 | ) 62 | meta = request.meta or {} 63 | echo = request.echo or {} 64 | 65 | if req_type == RequestMessageType.TEXT: 66 | if not request.text: 67 | raise HTTPException( 68 | status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, 69 | detail=f'Field text is required.', 70 | ) 71 | req = TextRequestMessage( 72 | uuid=_uuid, 73 | sender_id=sender_id, 74 | scope=scope, 75 | meta=meta, 76 | echo=echo, 77 | text=request.text, 78 | ) 79 | elif req_type == RequestMessageType.FILE: 80 | if not request.file_type: 81 | raise HTTPException( 82 | status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, 83 | detail=f'Field file_type is required.', 84 | ) 85 | if not request.file_name: 86 | raise HTTPException( 87 | status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, 88 | detail=f'Field file_name is required.', 89 | ) 90 | if not request.file_url: 91 | raise HTTPException( 92 | status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, 93 | detail=f'Field file_url is required.', 94 | ) 95 | 96 | try: 97 | async with aiohttp.ClientSession() as session: 98 | async with session.get( 99 | request.file_url, 100 | ) as r: 101 | if not r.ok: 102 | r.raise_for_status() 103 | file_content = await r.content.read() 104 | except ClientError as e: 105 | raise HTTPException( 106 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 107 | detail=str(e), 108 | ) 109 | 110 | req = FileRequestMessage( 111 | uuid=_uuid, 112 | sender_id=sender_id, 113 | scope=scope, 114 | meta=meta, 115 | echo=echo, 116 | file_type=request.file_type, 117 | file_name=request.file_name, 118 | content=file_content, 119 | ) 120 | else: 121 | raise HTTPException( 122 | status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, 123 | detail=f'Request message type {request.type} is not supported.', 124 | ) 125 | 126 | res = await bot.chat(conversation_id=conversation_id, message=req) 127 | response = ChatResponse( 128 | conversation_id=conversation_id, 129 | uuid=res.uuid, 130 | ack_uuid=res.ack_uuid, 131 | receiver_id=res.receiver_id, 132 | scope=str(res.scope.value), 133 | meta=res.meta, 134 | echo=res.echo, 135 | ) 136 | if isinstance(res, TextResponseMessage): 137 | response.type = ResponseMessageType.TEXT.value 138 | response.text = res.text 139 | elif isinstance(res, FallbackResponseMessage): 140 | response.type = ResponseMessageType.FALLBACK.value 141 | response.reason = res.reason 142 | response.suggestion = res.suggestion 143 | else: 144 | raise HTTPException( 145 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 146 | detail=f'Response message type is not supported.', 147 | ) 148 | 149 | return response 150 | -------------------------------------------------------------------------------- /ailingbot/shared/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ericzhang-cn/ailingbot/dd9962b021b2abb0995b0f0620c9cb24033ee0cd/ailingbot/shared/__init__.py -------------------------------------------------------------------------------- /ailingbot/shared/abc.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import abc 4 | import asyncio 5 | import signal 6 | 7 | from loguru import logger 8 | 9 | 10 | class AbstractAsyncComponent(abc.ABC): 11 | """Abstract class of asynchronous components.""" 12 | 13 | def __init__(self): 14 | self.initialized = False 15 | 16 | async def _initialize(self) -> None: 17 | """Do some initialize works.""" 18 | pass 19 | 20 | async def _finalize(self) -> None: 21 | """Do some cleanup works.""" 22 | pass 23 | 24 | async def initialize(self) -> None: 25 | """Initialize method wrapper.""" 26 | if self.initialized: 27 | return 28 | await self._initialize() 29 | self.initialized = True 30 | 31 | async def finalize(self) -> None: 32 | """Finalize method wrapper.""" 33 | if not self.initialized: 34 | return 35 | await self._finalize() 36 | self.initialized = False 37 | 38 | 39 | class AbstractAsyncRunnable(abc.ABC): 40 | """Abstract class for which runs tasks in event loop.""" 41 | 42 | def __init__(self, *, num_of_tasks: int = 1): 43 | """Build TaskRunner. 44 | 45 | :param num_of_tasks: Number of tasks to run. 46 | :type num_of_tasks: int 47 | """ 48 | self.started = False 49 | self.should_exit = False 50 | self.num_of_tasks = num_of_tasks 51 | 52 | @abc.abstractmethod 53 | async def _startup(self): 54 | """Do some initialize works.""" 55 | raise NotImplementedError() 56 | 57 | @abc.abstractmethod 58 | async def _main_task(self, *, number: int): 59 | """The main task to run. 60 | 61 | :param number: Serial number. 62 | :type number: int 63 | """ 64 | raise NotImplementedError() 65 | 66 | @abc.abstractmethod 67 | async def _shutdown(self): 68 | """Do some cleanup works.""" 69 | raise NotImplementedError() 70 | 71 | async def _run(self) -> None: 72 | """Run tasks in event loop.""" 73 | 74 | loop = asyncio.get_event_loop() 75 | loop.add_signal_handler( 76 | signal.SIGINT, 77 | lambda: asyncio.create_task( 78 | self.handle_exit_signal(sig_name='SIGINT') 79 | ), 80 | ) 81 | loop.add_signal_handler( 82 | signal.SIGTERM, 83 | lambda: asyncio.create_task( 84 | self.handle_exit_signal(sig_name='SIGTERM') 85 | ), 86 | ) 87 | 88 | await self.startup() 89 | await asyncio.gather( 90 | *[self.main_task(number=x) for x in range(self.num_of_tasks)] 91 | ) 92 | await self.shutdown() 93 | 94 | def run(self) -> None: 95 | """The entrypoint to run in event loop.""" 96 | asyncio.run(self._run()) 97 | 98 | async def handle_exit_signal(self, *, sig_name: str) -> None: 99 | """Callback function for handing SIGINT and SIGTERM signal. 100 | 101 | :param sig_name: Signal type. 102 | :type sig_name: str 103 | """ 104 | logger.info(f'The `{sig_name}` signal is received.') 105 | self.should_exit = True 106 | 107 | async def startup(self) -> None: 108 | """Startup method wrapper.""" 109 | if self.should_exit: 110 | return 111 | logger.info( 112 | f'Preparing to start the main loop of `{type(self).__name__}`.' 113 | ) 114 | await self._startup() 115 | self.started = True 116 | logger.info(f'The main loop of `{type(self).__name__}` started.') 117 | 118 | async def main_task(self, *, number: int) -> None: 119 | """Main task method wrapper. 120 | :param number: Serial number. 121 | :type number: int 122 | :return: 123 | :rtype: 124 | """ 125 | logger.info(f'Task{number} starts to process request message.') 126 | while not self.should_exit: 127 | await self._main_task(number=number) 128 | logger.info(f'Task{number} exit.') 129 | 130 | async def shutdown(self) -> None: 131 | """Shutdown method wrapper.""" 132 | if not self.should_exit: 133 | return 134 | if not self.started: 135 | return 136 | 137 | logger.info( 138 | f'Preparing to exit the main loop of `{type(self).__name__}`.' 139 | ) 140 | await self._shutdown() 141 | self.started = False 142 | logger.info(f'The main loop of `{type(self).__name__}` exited.') 143 | -------------------------------------------------------------------------------- /ailingbot/shared/errors.py: -------------------------------------------------------------------------------- 1 | class AilingBotError(Exception): 2 | """Base class for all customized errors. 3 | 4 | All customized errors may send to user, so the reason field must be easy to understand. 5 | """ 6 | 7 | def __init__( 8 | self, reason: str = '', *, critical: bool = False, suggestion: str = '' 9 | ): 10 | """Init. 11 | 12 | :param reason: Causes of error. 13 | :type reason: str 14 | :param critical: Whether the error is critical. Process should to exit if this is true. 15 | :type critical: bool 16 | :param suggestion: Suggestion that respond to user when error occurred. 17 | :type suggestion: str 18 | """ 19 | self.reason = reason 20 | self.critical = critical 21 | self.suggestion = suggestion 22 | 23 | def __str__(self): 24 | return self.reason 25 | 26 | 27 | class ExternalHTTPAPIError(AilingBotError): 28 | """Raised when calling external api failed.""" 29 | 30 | pass 31 | 32 | 33 | class EmptyQueueError(AilingBotError): 34 | """Raised when queue is empty no more message to consume.""" 35 | 36 | pass 37 | 38 | 39 | class FullQueueError(AilingBotError): 40 | """Raised when queue is full no more message could publish to.""" 41 | 42 | pass 43 | 44 | 45 | class BrokerError(AilingBotError): 46 | """Raised when connecting to broker or broker operation failed.""" 47 | 48 | pass 49 | 50 | 51 | class ChatPolicyError(AilingBotError): 52 | """Raised when chat policy error.""" 53 | 54 | pass 55 | 56 | 57 | class ConfigValidationError(AilingBotError): 58 | """Raised when configuration invalid.""" 59 | 60 | pass 61 | 62 | 63 | class ComponentNotFoundError(AilingBotError): 64 | """Raised when component not found.""" 65 | 66 | pass 67 | 68 | 69 | class UnsupportedMessageTypeError(AilingBotError): 70 | """Raised when there is an unsupported message type.""" 71 | -------------------------------------------------------------------------------- /ailingbot/shared/misc.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import typing 3 | 4 | from ailingbot.shared.errors import ComponentNotFoundError 5 | 6 | 7 | def get_class_dynamically(package_class_name: str) -> typing.Type: 8 | """Dynamically get class type by package path and class name. 9 | 10 | :param package_class_name: Package and class name like package_name.sub_package_name.ClassName 11 | :type package_class_name: str 12 | :return: Instance. 13 | :rtype: object 14 | """ 15 | module_path, class_name = package_class_name.rsplit('.', 1) 16 | try: 17 | module = importlib.import_module(module_path) 18 | return getattr(module, class_name) 19 | except (ImportError, AttributeError): 20 | raise ComponentNotFoundError( 21 | f'Class `{module_path}.{class_name}` not found.', critical=True 22 | ) 23 | -------------------------------------------------------------------------------- /img/command-line-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ericzhang-cn/ailingbot/dd9962b021b2abb0995b0f0620c9cb24033ee0cd/img/command-line-screenshot.png -------------------------------------------------------------------------------- /img/dingtalk-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ericzhang-cn/ailingbot/dd9962b021b2abb0995b0f0620c9cb24033ee0cd/img/dingtalk-screenshot.png -------------------------------------------------------------------------------- /img/feishu-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ericzhang-cn/ailingbot/dd9962b021b2abb0995b0f0620c9cb24033ee0cd/img/feishu-screenshot.png -------------------------------------------------------------------------------- /img/flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ericzhang-cn/ailingbot/dd9962b021b2abb0995b0f0620c9cb24033ee0cd/img/flow.png -------------------------------------------------------------------------------- /img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ericzhang-cn/ailingbot/dd9962b021b2abb0995b0f0620c9cb24033ee0cd/img/logo.png -------------------------------------------------------------------------------- /img/slack-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ericzhang-cn/ailingbot/dd9962b021b2abb0995b0f0620c9cb24033ee0cd/img/slack-screenshot.png -------------------------------------------------------------------------------- /img/swagger.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ericzhang-cn/ailingbot/dd9962b021b2abb0995b0f0620c9cb24033ee0cd/img/swagger.png -------------------------------------------------------------------------------- /img/wechatwork-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ericzhang-cn/ailingbot/dd9962b021b2abb0995b0f0620c9cb24033ee0cd/img/wechatwork-screenshot.png -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "ailingbot" 3 | version = "0.0.8" 4 | description = "An all-in-one solution to empower your IM bot with AI." 5 | authors = ["ericzhang-cn "] 6 | readme = "README.md" 7 | license = "MIT" 8 | 9 | [tool.poetry.dependencies] 10 | python = ">=3.9,<4.0" 11 | pydantic = "^1.10.2" 12 | cryptography = "^38.0.3" 13 | arrow = "^1.2.3" 14 | dynaconf = "^3.1.11" 15 | fastapi = "^0.86.0" 16 | xmltodict = "^0.13.0" 17 | uvicorn = "^0.19.0" 18 | loguru = "^0.6.0" 19 | aiohttp = { version = "^3.8.3", extras = ["speedups"] } 20 | tomli = "^2.0.1" 21 | babel = "^2.11.0" 22 | click = "^8.1.3" 23 | asgiref = "^3.5.2" 24 | tabulate = { version = "^0.9.0", extras = ["widechars"] } 25 | emoji = "^2.2.0" 26 | prompt-toolkit = "^3.0.33" 27 | langchain = "^0.0.214" 28 | openai = "^0.27.8" 29 | rich = "^13.4.2" 30 | tomlkit = "^0.11.8" 31 | chromadb = "^0.3.26" 32 | pypdf = "^3.11.0" 33 | tiktoken = "^0.4.0" 34 | sqlalchemy = "^2.0.17" 35 | cachetools = "^5.3.1" 36 | 37 | [tool.poetry.group.dev.dependencies] 38 | pytest = "^7.2.0" 39 | sphinx = "^5.3.0" 40 | pytest-asyncio = "^0.20.1" 41 | blue = "^0.9.1" 42 | 43 | [tool.poetry.scripts] 44 | ailingbot = "ailingbot.cli.cli:command_line_tools" 45 | 46 | [build-system] 47 | requires = ["poetry-core"] 48 | build-backend = "poetry.core.masonry.api" 49 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ericzhang-cn/ailingbot/dd9962b021b2abb0995b0f0620c9cb24033ee0cd/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /tests/shared/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ericzhang-cn/ailingbot/dd9962b021b2abb0995b0f0620c9cb24033ee0cd/tests/shared/__init__.py -------------------------------------------------------------------------------- /tests/shared/test_misc.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from ailingbot.shared.errors import AilingBotError 4 | from ailingbot.shared.misc import get_class_dynamically 5 | 6 | 7 | def test_get_class_dynamically(): 8 | klass = get_class_dynamically( 9 | 'ailingbot.channels.feishu.agent.FeishuAgent' 10 | ) 11 | assert klass.__name__ == 'FeishuAgent' 12 | 13 | with pytest.raises(AilingBotError): 14 | get_class_dynamically('ailingbot.not.exists.ClassName') 15 | --------------------------------------------------------------------------------