├── .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 | 
7 | 
8 | 
9 |
10 |
11 |
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 |
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 |
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 |
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 |
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 |
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 |
570 |
571 |
572 | # 📖User Guide
573 |
574 | ## Main Process
575 |
576 | The main processing flow of AilingBot is as follows:
577 |
578 |
579 |
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 | 
7 | 
8 | 
9 |
10 |
11 |
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 |
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 |
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 |
--------------------------------------------------------------------------------