├── .env.template ├── .github ├── ISSUE_TEMPLATE │ ├── banned_prompt_report.yml │ └── new_issue.yml └── workflows │ └── docker-build.yml ├── .gitignore ├── Dockerfile ├── README-EN.md ├── README.md ├── __init__.py ├── app ├── __init__.py ├── handler.py ├── routers.py ├── schema.py └── server.py ├── banned_words.txt ├── build.sh ├── entrypoint.sh ├── exceptions.py ├── lib ├── __init__.py ├── api │ ├── __init__.py │ ├── callback.py │ └── discord.py └── prompt.py ├── requirements.txt ├── server.py ├── start.sh ├── task ├── __init__.py └── bot │ ├── __init__.py │ ├── _typing.py │ ├── handler.py │ └── listener.py ├── task_bot.py └── util ├── _queue.py └── fetch.py /.env.template: -------------------------------------------------------------------------------- 1 | LOG_LEVEL=DEBUG 2 | USER_TOKEN=xxx 3 | BOT_TOKEN=xxx 4 | GUILD_ID=xxx 5 | CHANNEL_ID=xxx 6 | CALLBACK_URL=xxx 7 | # midjourney 并发数 8 | CONCUR_SIZE=3 9 | # 等待队列数 10 | WAIT_SIZE=10 11 | # 监听 midjourney bot 处理完任务后,回调 API 服务清除队列 12 | QUEUE_RELEASE_API=http://127.0.0.1:8062/v1/api/trigger/queue/release 13 | 14 | # discord api version 15 | DRAW_VERSION=1237876415471554623 16 | # proxy, default None 17 | PROXY_URL= 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/banned_prompt_report.yml: -------------------------------------------------------------------------------- 1 | name: "🔥 禁用词添加" 2 | description: 禁用词添加 3 | title: "Banned prompt: " 4 | labels: ["banned prompt"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | 非常感谢你的 issue report (๑>؂<๑) 10 | - type: input 11 | id: prompt 12 | attributes: 13 | label: 禁用词 14 | description: 输入您需要添加的 Midjourney 禁用词~ 15 | placeholder: | 16 | Prompt 17 | validations: 18 | required: true 19 | - type: checkboxes 20 | id: checkboxes 21 | attributes: 22 | label: 一点点的自我检查 23 | description: 在你提交 issue 之前,麻烦确认自己是否已经完成了以下检查: 24 | options: 25 | - label: 如果是网络问题,已经检查网络连接、设置是否正常,并经过了充分测试 26 | required: true 27 | - label: 本 prompt 在 [banned_words](https://raw.githubusercontent.com/yokonsan/midjourney-api/master/banned_words.txt) 中不存在 28 | required: true 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/new_issue.yml: -------------------------------------------------------------------------------- 1 | name: "🐛 新建 issue" 2 | description: 上报一个新的 issue 3 | title: "🐛 " 4 | labels: [] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | 非常感谢你的 issue report (๑>؂<๑),为了使我们能够更快地定位问题来源,请尽可能完整地填写本 Issue 表格 10 | - type: textarea 11 | id: description 12 | attributes: 13 | label: 问题简述 14 | description: 详述你所遇到的问题(如有报错也请粘贴在这里)~ 15 | placeholder: | 16 | 如果方便,请提供更加详细的日志信息 17 | validations: 18 | required: true 19 | - type: textarea 20 | id: reproduction 21 | attributes: 22 | label: 复现方式 23 | description: | 24 | 请在这里提供你复现改问题的方法。 25 | 为了节省彼此交流的时间,麻烦在提交 issue 前多次测试该问题是能够反复复现的(非网络问题)。 26 | placeholder: "注意在粘贴的命令中隐去所有隐私信息哦(*/ω\*)" 27 | validations: 28 | required: true 29 | - type: textarea 30 | id: environment-info 31 | attributes: 32 | label: 环境信息 33 | description: 请尽可能详细地供以下信息~ 34 | placeholder: 你的环境信息~ 35 | value: | 36 | - OS: 操作系统类型及其版本号 37 | - Python: Python 版本号 (`python --version`) 38 | - Others: 其它信息 39 | validations: 40 | required: true 41 | - type: textarea 42 | id: additional-context 43 | attributes: 44 | label: 额外信息 45 | description: 请尽可能提供一些你认为可能产生该问题的一些原因 46 | placeholder: 如有额外的信息,请填写在这里~ 47 | validations: 48 | required: false 49 | - type: checkboxes 50 | id: checkboxes 51 | attributes: 52 | label: 一点点的自我检查 53 | description: 在你提交 issue 之前,麻烦确认自己是否已经完成了以下检查: 54 | options: 55 | - label: 充分阅读 [README.md](https://github.com/yokonsan/midjourney-api),特别是与本 issue 相关的部分 56 | required: true 57 | - label: 如果是网络问题,已经检查网络连接、设置是否正常,并经过充分测试认为这是项目本身的问题 58 | required: true 59 | - label: 本 issue 在 [issues](https://github.com/yokonsan/midjourney-api/issues) 中并没有重复问题 60 | required: true 61 | -------------------------------------------------------------------------------- /.github/workflows/docker-build.yml: -------------------------------------------------------------------------------- 1 | name: Docker Build CI 2 | 3 | on: 4 | workflow_dispatch: 5 | release: 6 | types: [published] 7 | 8 | jobs: 9 | 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - 16 | name: Check out the repo 17 | uses: actions/checkout@v3 18 | - 19 | name: Set up QEMU 20 | uses: docker/setup-qemu-action@v2 21 | - 22 | name: Set up Docker Buildx 23 | uses: docker/setup-buildx-action@v2 24 | - 25 | name: Login to Docker Hub 26 | uses: docker/login-action@v2 27 | with: 28 | username: ${{ secrets.DOCKERHUB_USERNAME }} 29 | password: ${{ secrets.DOCKERHUB_TOKEN }} 30 | - 31 | name: Build and push 32 | uses: docker/build-push-action@v4 33 | with: 34 | push: true 35 | tags: kunyu/midjourney-api:1.0 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .cache 2 | .idea 3 | .env* 4 | log 5 | test/log 6 | test/**/log 7 | .pytest_cache 8 | .vscode 9 | *__pycache__* 10 | data 11 | test/data 12 | test/**/data 13 | 14 | # pipenv 15 | Pipfile 16 | Pipfile.lock 17 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10.6 2 | LABEL creator="yokon" email="944682328@qq.com" 3 | 4 | WORKDIR /app 5 | 6 | COPY . . 7 | RUN pip install --upgrade pip \ 8 | && pip install -i https://pypi.douban.com/simple/ -r requirements.txt \ 9 | && chmod +x entrypoint.sh 10 | 11 | ENTRYPOINT ["bash", "entrypoint.sh"] 12 | EXPOSE 8062 13 | CMD ["http"] -------------------------------------------------------------------------------- /README-EN.md: -------------------------------------------------------------------------------- 1 | # midjourney-api 2 | 3 | English / [简体中文](./README.md) 4 | 5 | Based on the Discord Midjourney API. 6 | 7 | **Add Midjourney Banned Words Entry [issue](https://github.com/yokonsan/midjourney-api/issues/new?assignees=&labels=banned+prompt&projects=&template=banned_prompt_report.yml&title=Banned+prompt%3A+)** 8 | 9 | Refer to the project integration demo: [issue31](https://github.com/yokonsan/midjourney-api/issues/31) 10 | 11 |
12 | 13 | ## Midjourney API Recommend 14 | If the above process & code is difficult for you to operate, or maintaining a pool of accounts consumes a lot of your energy and cost. Here is a highly integrated and cost effective [Midjouney API](https://ttapi.io/midjourney) platform recommendation **[TTAPI](https://ttapi.io)** for price and stability. 15 | 16 | **The TTAPI supports functions related to Midjourney:** 17 | - imagine 18 | - U V pan zoom 19 | - describe 20 | - blend 21 | - vary_region and all other advanced features included in Midjourney. 22 | - In addition to this there is support for the **Luma API**, the **Face Swap API**, the relatively inexpensive **ChatGPT**, and the **Claude API**. 23 | 24 | **How to start with TTAPI** 25 | 26 | - Just [Sign In With Github](https://ttapi.io/login), and than you will get 30 quota of free credits (can be used to 10 times imagine endpoint in fast mode for testing). 27 | - [For complete docs of TTAPI](https://ttapi.io/docs) 28 | 29 |
30 | 31 | 32 | ## UML 33 | 34 | ```mermaid 35 | sequenceDiagram 36 | participant ThirdServer 37 | participant APIServer 38 | participant DiscordAPI 39 | 40 | ThirdServer->>APIServer: Request interface to trigger task 41 | APIServer->>APIServer: Add to task queue 42 | APIServer->>DiscordAPI: Call interface to trigger drawing task 43 | APIServer-->>ThirdServer: Return whether the trigger was successful 44 | 45 | DiscordAPI->>DiscordAPI: Trigger Midjourney bot drawing task 46 | DiscordAPI->>DiscordAPI: Monitor MidJourney bot messages 47 | DiscordAPI-->>ThirdServer: Return real-time message monitoring 48 | DiscordAPI-->>APIServer: Clear queue task 49 | ``` 50 | 51 | ## Usage Requirements 52 | 53 | 1. Ensure the program's runtime environment can access Discord 54 | 2. Have Midjourney and Discord accounts 55 | 3. Create a Discord channel and add the bot, refer to the tutorial [Midjourney|How to Integrate into Your Own Platform](https://mp.weixin.qq.com/s?__biz=Mzg4MjkzMzc1Mg==&mid=2247484029&idx=1&sn=d3c458bba9459f19f05d13ab23f5f67e&chksm=cf4e68eaf839e1fc2db025bd9940d0f5e57862f1788c88215b4a66cb23f553a30c5f37ac3ae8&token=79614426&lang=zh_CN#rd) 56 | 57 | 58 | ## Installation and Startup 59 | 60 | ```bash 61 | git clone 62 | pip install -r requirements.txt 63 | ``` 64 | 65 | Rename the file `.env.template` to `.env` and fill in the parameter values: 66 | 67 | ``` 68 | USER_TOKEN=User token 69 | BOT_TOKEN=Bot token 70 | GUILD_ID=Server ID 71 | CHANNEL_ID=Channel ID 72 | CALLBACK_URL=Callback URL, default is HTTP POST request, used for receiving midjourney drawing progress and results 73 | ``` 74 | 75 | ### Direct Startup 76 | 77 | ```bash 78 | # Start the bot listener 79 | python task_bot.py 80 | # Start the HTTP service 81 | python server.py 82 | ``` 83 | 84 | #### Update 85 | 86 | ```bash 87 | git pull 88 | 89 | # Start the bot listener 90 | python task_bot.py 91 | # Start the HTTP service 92 | python server.py 93 | ``` 94 | 95 | ### Docker Startup 96 | 97 | Fill in the environment variables after `-e` in [start.sh](./start.sh) and start directly: 98 | 99 | ```bash 100 | sh start.sh 101 | ``` 102 | 103 | Or build the image locally: 104 | 105 | ```bash 106 | # Build the image 107 | sh build.sh 108 | # Start the container 109 | sh start.sh 110 | ``` 111 | 112 | #### Update 113 | 114 | ```bash 115 | docker rmi kunyu/midjourney-api:1.0 116 | sh start.sh 117 | ``` 118 | 119 | API `swagger` documentation: [http://127.0.0.1:8062/docs](http://127.0.0.1:8062/docs) 120 | 121 | `midjourney-api` provides interfaces: 122 | 123 | - [x] `/v1/api/trigger/imagine`: Trigger drawing task (image-to-image, add image link before the prompt) 124 | - [x] `/v1/api/trigger/upscale`: U 125 | - [x] `/v1/api/trigger/variation`: V 126 | - [x] `/v1/api/trigger/solo_variation`: Make Variations 127 | - [x] `/v1/api/trigger/solo_low_variation`: Vary(Subtle) 128 | - [x] `/v1/api/trigger/solo_high_variation`: Vary(Strong) 129 | - [x] `/v1/api/trigger/zoomout`: Zoom Out 2x/1.5x 130 | - [x] `/v1/api/trigger/expand`: ⬅️ ➡️ ⬆️ ⬇️ 131 | - [x] `/v1/api/trigger/reset`: Redraw 132 | - [x] `/v1/api/trigger/upload`: Upload image 133 | - [x] `/v1/api/trigger/describe`: Generate prompt by uploading image name 134 | - [x] `/v1/api/trigger/message`: Send image message, return image link for image-to-image functionality 135 | 136 | 137 | ## Usage 138 | 139 | ### Imagine 140 | 141 | Text-to-image 142 | 143 | ```bash 144 | curl -X 'POST' \ 145 | 'http://127.0.0.1:8062/v1/api/trigger/imagine' \ 146 | -H 'accept: application/json' \ 147 | -H 'Content-Type: application/json' \ 148 | -d '{ 149 | "prompt": "a cute cat" 150 | }' 151 | ``` 152 | 153 | Image-to-image, must include the image URL 154 | 155 | ```bash 156 | curl -X 'POST' \ 157 | 'http://127.0.0.1:8062/v1/api/trigger/imagine' \ 158 | -H 'accept: application/json' \ 159 | -H 'Content-Type: application/json' \ 160 | -d '{ 161 | "prompt": "a cute cat", 162 | "picurl": "https://xxxxxx/xxxxxxxxxxxx.jpg" 163 | }' 164 | ``` 165 | 166 | ### Upscale 167 | 168 | ```bash 169 | curl -X 'POST' \ 170 | 'http://127.0.0.1:8062/v1/api/trigger/upscale' \ 171 | -H 'accept: application/json' \ 172 | -H 'Content-Type: application/json' \ 173 | -d '{ 174 | "index": 1, 175 | "msg_id": "xxxxxxxxxx", 176 | "msg_hash": "xxxxx-xxx-xxxx-xxxx-xxxxxx", 177 | "trigger_id": "xxxxxxxxxx" 178 | }' 179 | ``` 180 | 181 | - `index`: Image index, values: 1, 2, 3, 4 182 | - `msg_id`: `imagine` callback message `id` field after drawing completion 183 | - `msg_hash`: `imagine` callback message `attachments[0].filename.split("_")[-1].split(".")[0]` after drawing completion 184 | - `trigger_id`: `imagine` callback message `trigger_id` field after drawing completion 185 | 186 | ### Variation 187 | 188 | ```bash 189 | curl -X 'POST' \ 190 | 'http://127.0.0.1:8062/v1/api/trigger/variation' \ 191 | -H 'accept: application/json' \ 192 | -H 'Content-Type: application/json' \ 193 | -d '{ 194 | "index": 2, 195 | "msg_id": "xxxxxxxxxx", 196 | "msg_hash": "xxxxx-xxx-xxxx-xxxx-xxxxxx", 197 | "trigger_id": "xxxxxxxxxx" 198 | }' 199 | ``` 200 | 201 | ### Solo Variation 202 | 203 | Perform "Make Variations" on a single image from `upscale` 204 | 205 | ```bash 206 | curl -X 'POST' \ 207 | 'http://127.0.0.1:8062/v1/api/trigger/solo_variation' \ 208 | -H 'accept: application/json' \ 209 | -H 'Content-Type: application/json' \ 210 | -d '{ 211 | "index": 1, 212 | "msg_id": "xxxxxxxxxx", 213 | "msg_hash": "xxxxx-xxx-xxxx-xxxx-xxxxxx", 214 | "trigger_id": "xxxxxxxxxx" 215 | }' 216 | ``` 217 | 218 | - `index`: Image index, not used here, value: 1 219 | - `msg_id`: `upscale` callback message `id` field after drawing completion 220 | - `msg_hash`: `upscale` callback message `attachments[0].filename.split("_")[-1].split(".")[0]` after drawing completion 221 | - `trigger_id`: `upscale` callback message `trigger_id` field after drawing completion 222 | 223 | ### Solo Low Variation 224 | 225 | Perform "Vary(Subtle)" on a single image from `upscale` 226 | 227 | ```bash 228 | curl -X 'POST' \ 229 | 'http://127.0.0.1:8062/v1/api/trigger/solo_low_variation' \ 230 | -H 'accept: application/json' \ 231 | -H 'Content-Type: application/json' \ 232 | -d '{ 233 | "index": 1, 234 | "msg_id": "xxxxxxxxxx", 235 | "msg_hash": "xxxxx-xxx-xxxx-xxxx-xxxxxx", 236 | "trigger_id": "xxxxxxxxxx" 237 | }' 238 | ``` 239 | 240 | - `index`: Image index, not used here, value: 1 241 | - `msg_id`: `upscale` callback message `id` field after drawing completion 242 | - `msg_hash`: `upscale` callback message `attachments[0].filename.split("_")[-1].split(".")[0]` after drawing completion 243 | - `trigger_id`: `upscale` callback message `trigger_id` field after drawing completion 244 | 245 | ### Solo High Variation 246 | 247 | Perform "Vary(Strong)" on a single image from `upscale` 248 | 249 | ```bash 250 | curl -X 'POST' \ 251 | 'http://127.0.0.1:8062/v1/api/trigger/solo_high_variation' \ 252 | -H 'accept: application/json' \ 253 | -H 'Content-Type: application/json' \ 254 | -d '{ 255 | "index": 1, 256 | "msg_id": "xxxxxxxxxx", 257 | "msg_hash": "xxxxx-xxx-xxxx-xxxx-xxxxxx", 258 | "trigger_id": "xxxxxxxxxx" 259 | }' 260 | ``` 261 | 262 | - `index`: Image index, not used here, value: 1 263 | - `msg_id`: `upscale` callback message `id` field after drawing completion 264 | - `msg_hash`: `upscale` callback message `attachments[0].filename.split("_")[-1].split(".")[0]` after drawing completion 265 | - `trigger_id`: `upscale` callback message `trigger_id` field after drawing completion 266 | 267 | 268 | ### Zoom Out 269 | 270 | Perform Zoom Out 2x/1.5x on a single image from `upscale` 271 | 272 | ```bash 273 | curl -X 'POST' \ 274 | 'http://127.0.0.1:8062/v1/api/trigger/zoomout' \ 275 | -H 'accept: application/json' \ 276 | -H 'Content-Type: application/json' \ 277 | -d '{ 278 | "msg_id": "xxxxxxxxxx", 279 | "msg_hash": "xxxxx-xxx-xxxx-xxxx-xxxxxx", 280 | "zoomout": 50 281 | "trigger_id": "xxxxxxxxxx" 282 | }' 283 | ``` 284 | 285 | - `zoomout`: Image enlargement (Outpaint) factor, 2x -> 50, 1.5x -> 75 286 | 287 | 288 | ### Expand 289 | 290 | Perform expansion in a specific direction on a single image from `upscale` 291 | 292 | ```bash 293 | curl -X 'POST' \ 294 | 'http://127.0.0.1:8062/v1/api/trigger/expand' \ 295 | -H 'accept: application/json' \ 296 | -H 'Content-Type: application/json' \ 297 | -d '{ 298 | "msg_id": "xxxxxxxxxx", 299 | "msg_hash": "xxxxx-xxx-xxxx-xxxx-xxxxxx", 300 | "direction": "up" 301 | "trigger_id": "xxxxxxxxxx" 302 | }' 303 | ``` 304 | 305 | - `direction`: Image expansion direction, values: left/right/up/down 306 | 307 | 308 | ### Reset 309 | 310 | ```bash 311 | curl -X 'POST' \ 312 | 'http://127.0.0.1:8062/v1/api/trigger/reset' \ 313 | -H 'accept: application/json' \ 314 | -H 'Content-Type: application/json' \ 315 | -d '{ 316 | "msg_id": "xxxxxxxxxx", 317 | "msg_hash": "xxxxx-xxx-xxxx-xxxx-xxxxxx", 318 | "trigger_id": "xxxxxxxxxx" 319 | }' 320 | ``` 321 | 322 | ### Describe 323 | 324 | 1. First, upload the image 325 | 326 | ```bash 327 | curl -X 'POST' \ 328 | 'http://127.0.0.1:8062/v1/api/trigger/upload' \ 329 | -H 'accept: application/json' \ 330 | -H 'Content-Type: multipart/form-data' \ 331 | -F 'file=@cH16Ifh.jpg;type=image/jpeg' 332 | ``` 333 | 334 | 2. Based on the returned image filename, call describe 335 | 336 | ```bash 337 | curl -X 'POST' \ 338 | 'http://127.0.0.1:8062/v1/api/trigger/describe' \ 339 | -H 'accept: application/json' \ 340 | -H 'Content-Type: application/json' \ 341 | -d '{ 342 | "upload_filename": "b56ca21a-5fbe-40b4-89ab-6e0aa732f561/9231228408.jpg", 343 | "trigger_id": "9231228408" 344 | }' 345 | ``` 346 | 347 | - `trigger_id`: Use the trigger_id returned by upload first 348 | - `upload_filename`: Filename returned by upload 349 | 350 | ### Message 351 | 352 | Same as `describe`, first `/v1/api/trigger/upload` to upload the image, then send the message based on the returned filename: 353 | 354 | ```bash 355 | curl -X 'POST' \ 356 | 'http://127.0.0.1:8062/v1/api/trigger/message' \ 357 | -H 'accept: application/json' \ 358 | -H 'Content-Type: application/json' \ 359 | -d '{ 360 | "upload_filename": "560a1e26-36a2-4d5f-a48d-9dd877642b51/7185811546.jpg" 361 | }' 362 | ``` 363 | 364 | After sending the image, an image link will be returned. 365 | This link is used in image-to-image, concatenating the prompt in the form `ImageURL Prompt`, calling `/v1/api/trigger/imagine`. 366 | 367 | 368 | ## Features 369 | 370 | - [x] imagine 371 | - [x] upscale 372 | - [x] variation 373 | - [x] solo_variation 374 | - [x] solo_low_variation 375 | - [x] solo_high_variation 376 | - [x] zoomout 377 | - [x] expand 378 | - [x] reset 379 | - [x] describe 380 | - [x] Image-to-image (obtain the link of the uploaded image) 381 | - [x] Sensitive word filtering and reporting 382 | - [x] Task queue (in-memory storage, external storage is not preferred, but can add exception handling for persistence) 383 | - [ ] tests 384 | 385 | 386 | ## enjoy it 387 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # midjourney-api 2 | 3 | 简体中文 / [English](./README-EN.md) 4 | 5 | 基于 Discord 的 Midjourney API。 6 | 7 | **添加 Midjourney 违禁词入口 [issue](https://github.com/yokonsan/midjourney-api/issues/new?assignees=&labels=banned+prompt&projects=&template=banned_prompt_report.yml&title=Banned+prompt%3A+)** 8 | 9 | 项目集成 Demo 参考:[issue31](https://github.com/yokonsan/midjourney-api/issues/31) 10 | 11 | 12 |
13 | 14 | ## Midjourney API 平台推荐 15 | 如果以上流程&代码对你来说操作有困难,或者维护账户池消耗了你们大量的精力与成本。这里有一个高度集成且价格与稳定性具有超高性价比的 [Midjouney API](https://ttapi.io/midjourney) 平台推荐 **[TTAPI](https://ttapi.io)** 16 | 17 | **TTAPI 平台对于Midjourney相关功能支持:** 18 | - imagine 19 | - U V pan zoom 20 | - describe 21 | - blend 22 | - vary_region等等所有Midjourney官方包含的高级功能 23 | - 除此以外还支持**Luma API**、**换脸API**、相对价格比较优惠的**ChatGPT以及Claude API** 24 | 25 | **TTAPI 接入** 26 | 27 | - 进入[平台github一键登录](https://ttapi.io/login)即可获取30配额的免费额度(可用于fast模式下执行10次imagine接口用作测试) 28 | - [平台接口文档地址](https://docs-zh.mjapiapp.com/) 29 | 30 |
31 | 32 | 33 | ## UML 34 | 35 | ```mermaid 36 | sequenceDiagram 37 | participant ThirdServer 38 | participant APIServer 39 | participant DiscordAPI 40 | 41 | ThirdServer->>APIServer: 请求接口触发任务 42 | APIServer->>APIServer: 放入任务队列 43 | APIServer->>DiscordAPI: 调接口触发绘画任务 44 | APIServer-->>ThirdServer: 返回是否触发成功 45 | 46 | DiscordAPI->>DiscordAPI: 触发Midjourney bot绘画任务 47 | DiscordAPI->>DiscordAPI: 监听MidJourney bot消息 48 | DiscordAPI-->>ThirdServer: 返回监听实时消息 49 | DiscordAPI-->>APIServer: 清除队列任务 50 | ``` 51 | 52 | 53 | ## 使用条件 54 | 55 | 1. 确保程序启动环境能访问 Discord 56 | 2. 已有 Midjourney、Discord 账户 57 | 3. 创建 Discord 频道并添加机器人,参考教程 [Midjourney|如何集成到自己的平台](https://mp.weixin.qq.com/s?__biz=Mzg4MjkzMzc1Mg==&mid=2247484029&idx=1&sn=d3c458bba9459f19f05d13ab23f5f67e&chksm=cf4e68eaf839e1fc2db025bd9940d0f5e57862f1788c88215b4a66cb23f553a30c5f37ac3ae8&token=79614426&lang=zh_CN#rd) 58 | 59 | 60 | ## 安装启动 61 | 62 | ```bash 63 | git clone 64 | pip install -r requirements.txt 65 | ``` 66 | 67 | 将文件`.env.template`重命名为`.env`,并填入参数值: 68 | 69 | ``` 70 | USER_TOKEN=用户token 71 | BOT_TOKEN=机器人token 72 | GUILD_ID=服务器ID 73 | CHANNEL_ID=频道ID 74 | CALLBACK_URL=回调地址,默认http post请求,用于接收 midjourney 作图进度和结果的服务 75 | ``` 76 | 77 | ### 直接启动 78 | 79 | ```bash 80 | # 启动监听机器人 81 | python task_bot.py 82 | # 启动http服务 83 | python server.py 84 | ``` 85 | 86 | #### 更新 87 | 88 | ```bash 89 | git pull 90 | 91 | # 启动监听机器人 92 | python task_bot.py 93 | # 启动http服务 94 | python server.py 95 | ``` 96 | 97 | ### docker 启动 98 | 99 | 填写 [start.sh](./start.sh) 中 `-e` 后的环境变量,直接启动: 100 | 101 | ```bash 102 | sh start.sh 103 | ``` 104 | 105 | 或者本地构建镜像: 106 | 107 | ```bash 108 | # 构建镜像 109 | sh build.sh 110 | # 启动容器 111 | sh start.sh 112 | ``` 113 | 114 | #### 更新 115 | 116 | ```bash 117 | docker rmi kunyu/midjourney-api:1.0 118 | sh start.sh 119 | ``` 120 | 121 | 接口`swagger`文档:[http://127.0.0.1:8062/docs](http://127.0.0.1:8062/docs) 122 | 123 | `midjourney-api` 提供接口: 124 | 125 | - [x] `/v1/api/trigger/imagine`:触发绘画任务(图生图,Prompt 前加上图片链接即可) 126 | - [x] `/v1/api/trigger/upscale`:U 127 | - [x] `/v1/api/trigger/variation`:V 128 | - [x] `/v1/api/trigger/solo_variation`:Make Variations 129 | - [x] `/v1/api/trigger/solo_low_variation`:Vary(Subtle) 130 | - [x] `/v1/api/trigger/solo_high_variation`:Vary(Strong) 131 | - [x] `/v1/api/trigger/zoomout`:Zoom Out 2x/1.5x 132 | - [x] `/v1/api/trigger/expand`:⬅️ ➡️ ⬆️ ⬇️ 133 | - [x] `/v1/api/trigger/reset`:重绘 134 | - [x] `/v1/api/trigger/upload`:上传图片 135 | - [x] `/v1/api/trigger/describe`:通过上传图片名,生成 Prompt 136 | - [x] `/v1/api/trigger/message`:发送图片消息,返回图片链接,用于图生图功能 137 | 138 | 139 | ## 使用 140 | 141 | ### imagine 142 | 143 | 文生图 144 | 145 | ```bash 146 | curl -X 'POST' \ 147 | 'http://127.0.0.1:8062/v1/api/trigger/imagine' \ 148 | -H 'accept: application/json' \ 149 | -H 'Content-Type: application/json' \ 150 | -d '{ 151 | "prompt": "a cute cat" 152 | }' 153 | ``` 154 | 155 | 图生图,需带上图片 URL 156 | 157 | ```bash 158 | curl -X 'POST' \ 159 | 'http://127.0.0.1:8062/v1/api/trigger/imagine' \ 160 | -H 'accept: application/json' \ 161 | -H 'Content-Type: application/json' \ 162 | -d '{ 163 | "prompt": "a cute cat", 164 | "picurl": "https://xxxxxx/xxxxxxxxxxxx.jpg" 165 | }' 166 | ``` 167 | 168 | ### upscale 169 | 170 | ```bash 171 | curl -X 'POST' \ 172 | 'http://127.0.0.1:8062/v1/api/trigger/upscale' \ 173 | -H 'accept: application/json' \ 174 | -H 'Content-Type: application/json' \ 175 | -d '{ 176 | "index": 1, 177 | "msg_id": "xxxxxxxxxx", 178 | "msg_hash": "xxxxx-xxx-xxxx-xxxx-xxxxxx", 179 | "trigger_id": "xxxxxxxxxx" 180 | }' 181 | ``` 182 | 183 | - `index`: 图片索引,取值:1、2、3、4 184 | - `msg_id`: `imagine` 绘画完成后回调报文 `id` 字段 185 | - `msg_hash`: `imagine` 绘画完成后回调报文 `attachments[0].filename.split("_")[-1].split(".").[0]` 186 | - `trigger_id`: `imagine` 绘画完成后回调报文 `trigger_id` 字段 187 | 188 | ### variation 189 | 190 | ```bash 191 | curl -X 'POST' \ 192 | 'http://127.0.0.1:8062/v1/api/trigger/variation' \ 193 | -H 'accept: application/json' \ 194 | -H 'Content-Type: application/json' \ 195 | -d '{ 196 | "index": 2, 197 | "msg_id": "xxxxxxxxxx", 198 | "msg_hash": "xxxxx-xxx-xxxx-xxxx-xxxxxx", 199 | "trigger_id": "xxxxxxxxxx" 200 | }' 201 | ``` 202 | 203 | ### solo_variation 204 | 205 | 对 `upscale` 的单张图片进行 "Make Variations" 操作 206 | 207 | ```bash 208 | curl -X 'POST' \ 209 | 'http://127.0.0.1:8062/v1/api/trigger/solo_variation' \ 210 | -H 'accept: application/json' \ 211 | -H 'Content-Type: application/json' \ 212 | -d '{ 213 | "index": 1, 214 | "msg_id": "xxxxxxxxxx", 215 | "msg_hash": "xxxxx-xxx-xxxx-xxxx-xxxxxx", 216 | "trigger_id": "xxxxxxxxxx" 217 | }' 218 | ``` 219 | 220 | - `index`: 图片索引,此处无用,取值:1 221 | - `msg_id`: `upscale` 绘画完成后回调报文 `id` 字段 222 | - `msg_hash`: `upscale` 绘画完成后回调报文 `attachments[0].filename.split("_")[-1].split(".").[0]` 223 | - `trigger_id`: `upscale` 绘画完成后回调报文 `trigger_id` 字段 224 | 225 | ### solo_low_variation 226 | 227 | 对 `upscale` 的单张图片进行 "Vary(Subtle)" 操作 228 | 229 | ```bash 230 | curl -X 'POST' \ 231 | 'http://127.0.0.1:8062/v1/api/trigger/solo_low_variation' \ 232 | -H 'accept: application/json' \ 233 | -H 'Content-Type: application/json' \ 234 | -d '{ 235 | "index": 1, 236 | "msg_id": "xxxxxxxxxx", 237 | "msg_hash": "xxxxx-xxx-xxxx-xxxx-xxxxxx", 238 | "trigger_id": "xxxxxxxxxx" 239 | }' 240 | ``` 241 | 242 | - `index`: 图片索引,此处无用,取值:1 243 | - `msg_id`: `upscale` 绘画完成后回调报文 `id` 字段 244 | - `msg_hash`: `upscale` 绘画完成后回调报文 `attachments[0].filename.split("_")[-1].split(".").[0]` 245 | - `trigger_id`: `upscale` 绘画完成后回调报文 `trigger_id` 字段 246 | 247 | ### solo_high_variation 248 | 249 | 对 `upscale` 的单张图片进行 "Vary(Strong)" 操作 250 | 251 | ```bash 252 | curl -X 'POST' \ 253 | 'http://127.0.0.1:8062/v1/api/trigger/solo_high_variation' \ 254 | -H 'accept: application/json' \ 255 | -H 'Content-Type: application/json' \ 256 | -d '{ 257 | "index": 1, 258 | "msg_id": "xxxxxxxxxx", 259 | "msg_hash": "xxxxx-xxx-xxxx-xxxx-xxxxxx", 260 | "trigger_id": "xxxxxxxxxx" 261 | }' 262 | ``` 263 | 264 | - `index`: 图片索引,此处无用,取值:1 265 | - `msg_id`: `upscale` 绘画完成后回调报文 `id` 字段 266 | - `msg_hash`: `upscale` 绘画完成后回调报文 `attachments[0].filename.split("_")[-1].split(".").[0]` 267 | - `trigger_id`: `upscale` 绘画完成后回调报文 `trigger_id` 字段 268 | 269 | 270 | ### zoomout 271 | 272 | 对 `upscale` 的单张图片进行 Zoom Out 2x/1.5x 操作 273 | 274 | ```bash 275 | curl -X 'POST' \ 276 | 'http://127.0.0.1:8062/v1/api/trigger/zoomout' \ 277 | -H 'accept: application/json' \ 278 | -H 'Content-Type: application/json' \ 279 | -d '{ 280 | "msg_id": "xxxxxxxxxx", 281 | "msg_hash": "xxxxx-xxx-xxxx-xxxx-xxxxxx", 282 | "zoomout": 50 283 | "trigger_id": "xxxxxxxxxx" 284 | }' 285 | ``` 286 | 287 | - `zoomout`: 图片扩大(Outpaint)系数,2x -> 50、1.5x -> 75 288 | 289 | 290 | ### expand 291 | 292 | 对 `upscale` 的单张图片进行某方向的扩展操作 293 | 294 | ```bash 295 | curl -X 'POST' \ 296 | 'http://127.0.0.1:8062/v1/api/trigger/expand' \ 297 | -H 'accept: application/json' \ 298 | -H 'Content-Type: application/json' \ 299 | -d '{ 300 | "msg_id": "xxxxxxxxxx", 301 | "msg_hash": "xxxxx-xxx-xxxx-xxxx-xxxxxx", 302 | "direction": "up" 303 | "trigger_id": "xxxxxxxxxx" 304 | }' 305 | ``` 306 | 307 | - `direction`: 图片扩大方向,取值:left/right/up/down 308 | 309 | 310 | ### reset 311 | 312 | ```bash 313 | curl -X 'POST' \ 314 | 'http://127.0.0.1:8062/v1/api/trigger/reset' \ 315 | -H 'accept: application/json' \ 316 | -H 'Content-Type: application/json' \ 317 | -d '{ 318 | "msg_id": "xxxxxxxxxx", 319 | "msg_hash": "xxxxx-xxx-xxxx-xxxx-xxxxxx", 320 | "trigger_id": "xxxxxxxxxx" 321 | }' 322 | ``` 323 | 324 | ### describe 325 | 326 | 1. 先上传图片 327 | 328 | ```bash 329 | curl -X 'POST' \ 330 | 'http://127.0.0.1:8062/v1/api/trigger/upload' \ 331 | -H 'accept: application/json' \ 332 | -H 'Content-Type: multipart/form-data' \ 333 | -F 'file=@cH16Ifh.jpg;type=image/jpeg' 334 | ``` 335 | 336 | 2. 根据返回的图片文件名,调用 describe 337 | 338 | ```bash 339 | curl -X 'POST' \ 340 | 'http://127.0.0.1:8062/v1/api/trigger/describe' \ 341 | -H 'accept: application/json' \ 342 | -H 'Content-Type: application/json' \ 343 | -d '{ 344 | "upload_filename": "b56ca21a-5fbe-40b4-89ab-6e0aa732f561/9231228408.jpg", 345 | "trigger_id": "9231228408" 346 | }' 347 | ``` 348 | 349 | - `trigger_id` 先用 upload 返回的 trigger_id 350 | - `upload_filename` upload 返回的文件名 351 | 352 | ### message 353 | 354 | 和 `describe` 一样,先 `/v1/api/trigger/upload` 上传图片,然后根据返回文件名,发送消息: 355 | 356 | ```bash 357 | curl -X 'POST' \ 358 | 'http://127.0.0.1:8062/v1/api/trigger/message' \ 359 | -H 'accept: application/json' \ 360 | -H 'Content-Type: application/json' \ 361 | -d '{ 362 | "upload_filename": "560a1e26-36a2-4d5f-a48d-9dd877642b51/7185811546.jpg" 363 | }' 364 | ``` 365 | 366 | 发送图片后,会返回图片链接。 367 | 该链接用于以图生图中,拼接 Prompt 形如 `图片URL Prompt`,调用 `/v1/api/trigger/imagine`。 368 | 369 | 370 | ## 功能 371 | 372 | - [x] imagine 373 | - [x] upscale 374 | - [x] variation 375 | - [x] solo_variation 376 | - [x] solo_low_variation 377 | - [x] solo_high_variation 378 | - [x] zoomout 379 | - [x] expand 380 | - [x] reset 381 | - [x] describe 382 | - [x] 图生图(获取到上传图片的链接) 383 | - [x] 敏感词过滤上报 384 | - [x] 任务队列(内存存储,不希望引入外建,可加入异常落盘) 385 | - [ ] tests 386 | 387 | ## enjoy it 388 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from os import getenv 3 | 4 | from dotenv import load_dotenv 5 | from loguru import logger 6 | 7 | # env config load 8 | load_dotenv() 9 | 10 | # logger config 11 | _lvl = getenv("LOG_LEVEL", default="ERROR") 12 | _format = '{time:%Y-%m-%d %H:%M:%S} | ' + \ 13 | '{level} | ' + \ 14 | '{process.id}-{thread.id} | ' + \ 15 | '"{file.path}:{line}":{function} ' + \ 16 | '- {message}' 17 | logger.remove() 18 | logger.add( 19 | sys.stdout, level=_lvl, format=_format, colorize=True, 20 | ) 21 | 22 | logger.add( 23 | f"log/mj-api.log", 24 | level=_lvl, 25 | format=_format, 26 | rotation="00:00", 27 | retention="3 days", 28 | backtrace=True, 29 | diagnose=True, 30 | enqueue=True 31 | ) 32 | -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yokonsan/midjourney-api/a5781c4e5123624e55623bb468f0f59046e643f0/app/__init__.py -------------------------------------------------------------------------------- /app/handler.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import time 3 | from functools import wraps 4 | from typing import Union 5 | 6 | from fastapi import status 7 | from fastapi.responses import JSONResponse 8 | 9 | from exceptions import BannedPromptError 10 | from lib.prompt import BANNED_PROMPT 11 | 12 | PROMPT_PREFIX = "<#" 13 | PROMPT_SUFFIX = "#>" 14 | 15 | 16 | def check_banned(prompt: str): 17 | words = set(w.lower() for w in prompt.split()) 18 | if len(words & BANNED_PROMPT) != 0: 19 | raise BannedPromptError(f"banned prompt: {prompt}") 20 | 21 | 22 | def unique_id(): 23 | """生成唯一的 10 位数字,作为任务 ID""" 24 | return int(hashlib.sha256(str(time.time()).encode("utf-8")).hexdigest(), 16) % 10**10 25 | 26 | 27 | def prompt_handler(prompt: str, picurl: Union[str, None] = None): 28 | """ 29 | 拼接 Prompt 形如: <#1234567890#>a cute cat 30 | """ 31 | check_banned(prompt) 32 | 33 | trigger_id = str(unique_id()) 34 | 35 | if not picurl and prompt.startswith(("http://", "https://")): 36 | picurl, _, prompt = prompt.partition(" ") 37 | 38 | return trigger_id, f"{picurl+' ' if picurl else ''}{PROMPT_PREFIX}{trigger_id}{PROMPT_SUFFIX}{prompt}" 39 | 40 | 41 | def http_response(func): 42 | @wraps(func) 43 | async def router(*args, **kwargs): 44 | trigger_id, resp = await func(*args, **kwargs) 45 | if resp is not None: 46 | code, trigger_result = status.HTTP_200_OK, "success" 47 | else: 48 | code, trigger_result = status.HTTP_400_BAD_REQUEST, "fail" 49 | 50 | return JSONResponse( 51 | status_code=code, 52 | content={"trigger_id": trigger_id, "trigger_result": trigger_result} 53 | ) 54 | 55 | return router 56 | -------------------------------------------------------------------------------- /app/routers.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, UploadFile 2 | 3 | from lib.api import discord 4 | from lib.api.discord import TriggerType 5 | from util._queue import taskqueue 6 | from .handler import prompt_handler, unique_id 7 | from .schema import ( 8 | TriggerExpandIn, 9 | TriggerImagineIn, 10 | TriggerUVIn, 11 | TriggerResetIn, 12 | QueueReleaseIn, 13 | TriggerResponse, 14 | TriggerZoomOutIn, 15 | UploadResponse, 16 | TriggerDescribeIn, 17 | SendMessageResponse, 18 | SendMessageIn, 19 | ) 20 | 21 | router = APIRouter() 22 | 23 | 24 | @router.post("/imagine", response_model=TriggerResponse) 25 | async def imagine(body: TriggerImagineIn): 26 | trigger_id, prompt = prompt_handler(body.prompt, body.picurl) 27 | trigger_type = TriggerType.generate.value 28 | 29 | taskqueue.put(trigger_id, discord.generate, prompt) 30 | return {"trigger_id": trigger_id, "trigger_type": trigger_type} 31 | 32 | 33 | @router.post("/upscale", response_model=TriggerResponse) 34 | async def upscale(body: TriggerUVIn): 35 | trigger_id = body.trigger_id 36 | trigger_type = TriggerType.upscale.value 37 | 38 | taskqueue.put(trigger_id, discord.upscale, **body.dict()) 39 | return {"trigger_id": trigger_id, "trigger_type": trigger_type} 40 | 41 | 42 | @router.post("/variation", response_model=TriggerResponse) 43 | async def variation(body: TriggerUVIn): 44 | trigger_id = body.trigger_id 45 | trigger_type = TriggerType.variation.value 46 | 47 | taskqueue.put(trigger_id, discord.variation, **body.dict()) 48 | return {"trigger_id": trigger_id, "trigger_type": trigger_type} 49 | 50 | 51 | @router.post("/reset", response_model=TriggerResponse) 52 | async def reset(body: TriggerResetIn): 53 | trigger_id = body.trigger_id 54 | trigger_type = TriggerType.reset.value 55 | 56 | taskqueue.put(trigger_id, discord.reset, **body.dict()) 57 | return {"trigger_id": trigger_id, "trigger_type": trigger_type} 58 | 59 | 60 | @router.post("/describe", response_model=TriggerResponse) 61 | async def describe(body: TriggerDescribeIn): 62 | trigger_id = body.trigger_id 63 | trigger_type = TriggerType.describe.value 64 | 65 | taskqueue.put(trigger_id, discord.describe, **body.dict()) 66 | return {"trigger_id": trigger_id, "trigger_type": trigger_type} 67 | 68 | 69 | @router.post("/upload", response_model=UploadResponse) 70 | async def upload_attachment(file: UploadFile): 71 | if not file.content_type.startswith("image/"): 72 | return {"message": "must image"} 73 | 74 | trigger_id = str(unique_id()) 75 | filename = f"{trigger_id}.jpg" 76 | file_size = file.size 77 | attachment = await discord.upload_attachment(filename, file_size, await file.read()) 78 | if not (attachment and attachment.get("upload_url")): 79 | return {"message": "Failed to upload image"} 80 | 81 | return { 82 | "upload_filename": attachment.get("upload_filename"), 83 | "upload_url": attachment.get("upload_url"), 84 | "trigger_id": trigger_id, 85 | } 86 | 87 | 88 | @router.post("/message", response_model=SendMessageResponse) 89 | async def send_message(body: SendMessageIn): 90 | picurl = await discord.send_attachment_message(body.upload_filename) 91 | if not picurl: 92 | return {"message": "Failed to send message"} 93 | 94 | return {"picurl": picurl} 95 | 96 | 97 | @router.post("/queue/release", response_model=TriggerResponse) 98 | async def queue_release(body: QueueReleaseIn): 99 | """bot 清除队列任务""" 100 | taskqueue.pop(body.trigger_id) 101 | 102 | return body 103 | 104 | 105 | @router.post("/solo_variation", response_model=TriggerResponse) 106 | async def solo_variation(body: TriggerUVIn): 107 | trigger_id = body.trigger_id 108 | trigger_type = TriggerType.solo_variation.value 109 | taskqueue.put(trigger_id, discord.solo_variation, **body.dict()) 110 | 111 | # 返回结果 112 | return {"trigger_id": trigger_id, "trigger_type": trigger_type} 113 | 114 | @router.post("/solo_low_variation", response_model=TriggerResponse) 115 | async def solo_low_variation(body: TriggerUVIn): 116 | trigger_id = body.trigger_id 117 | trigger_type = TriggerType.solo_low_variation.value 118 | taskqueue.put(trigger_id, discord.solo_low_variation, **body.dict()) 119 | 120 | # 返回结果 121 | return {"trigger_id": trigger_id, "trigger_type": trigger_type} 122 | 123 | @router.post("/solo_high_variation", response_model=TriggerResponse) 124 | async def solo_high_variation(body: TriggerUVIn): 125 | trigger_id = body.trigger_id 126 | trigger_type = TriggerType.solo_high_variation.value 127 | taskqueue.put(trigger_id, discord.solo_high_variation, **body.dict()) 128 | 129 | # 返回结果 130 | return {"trigger_id": trigger_id, "trigger_type": trigger_type} 131 | 132 | @router.post("/expand", response_model=TriggerResponse) 133 | async def expand(body: TriggerExpandIn): 134 | trigger_id = body.trigger_id 135 | trigger_type = TriggerType.expand.value 136 | taskqueue.put(trigger_id, discord.expand, **body.dict()) 137 | 138 | # 返回结果 139 | return {"trigger_id": trigger_id, "trigger_type": trigger_type} 140 | 141 | 142 | @router.post("/zoomout", response_model=TriggerResponse) 143 | async def zoomout(body: TriggerZoomOutIn): 144 | trigger_id = body.trigger_id 145 | trigger_type = TriggerType.zoomout.value 146 | taskqueue.put(trigger_id, discord.zoomout, **body.dict()) 147 | 148 | # 返回结果 149 | return {"trigger_id": trigger_id, "trigger_type": trigger_type} 150 | 151 | -------------------------------------------------------------------------------- /app/schema.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from pydantic import BaseModel 4 | 5 | 6 | class TriggerImagineIn(BaseModel): 7 | prompt: str 8 | picurl: Optional[str] 9 | 10 | 11 | class TriggerUVIn(BaseModel): 12 | index: int 13 | msg_id: str 14 | msg_hash: str 15 | 16 | trigger_id: str # 供业务定位触发ID,/trigger/imagine 接口返回的 trigger_id 17 | 18 | 19 | class TriggerResetIn(BaseModel): 20 | msg_id: str 21 | msg_hash: str 22 | 23 | trigger_id: str # 供业务定位触发ID,/trigger/imagine 接口返回的 trigger_id 24 | 25 | 26 | class TriggerExpandIn(BaseModel): 27 | msg_id: str 28 | msg_hash: str 29 | direction: str # right/left/up/down 30 | 31 | trigger_id: str # 供业务定位触发ID,/trigger/imagine 接口返回的 trigger_id 32 | 33 | class TriggerZoomOutIn(BaseModel): 34 | msg_id: str 35 | msg_hash: str 36 | zoomout: int # 2x: 50; 1.5x: 75 37 | 38 | trigger_id: str # 供业务定位触发ID,/trigger/imagine 接口返回的 trigger_id 39 | 40 | 41 | class TriggerDescribeIn(BaseModel): 42 | upload_filename: str 43 | trigger_id: str 44 | 45 | 46 | class QueueReleaseIn(BaseModel): 47 | trigger_id: str 48 | 49 | 50 | class TriggerResponse(BaseModel): 51 | message: str = "success" 52 | trigger_id: str 53 | trigger_type: str = "" 54 | 55 | 56 | class UploadResponse(BaseModel): 57 | message: str = "success" 58 | upload_filename: str = "" 59 | upload_url: str = "" 60 | trigger_id: str 61 | 62 | 63 | class SendMessageIn(BaseModel): 64 | upload_filename: str 65 | 66 | 67 | class SendMessageResponse(BaseModel): 68 | message: str = "success" 69 | picurl: str 70 | -------------------------------------------------------------------------------- /app/server.py: -------------------------------------------------------------------------------- 1 | import uvicorn 2 | from fastapi import FastAPI, status 3 | from fastapi.exceptions import RequestValidationError 4 | from fastapi.responses import JSONResponse 5 | 6 | from exceptions import APPBaseException, ErrorCode 7 | 8 | 9 | def init_app(): 10 | _app = FastAPI(title="Midjourney API") 11 | 12 | register_blueprints(_app) 13 | exc_handler(_app) 14 | 15 | return _app 16 | 17 | 18 | def exc_handler(_app): 19 | @_app.exception_handler(RequestValidationError) 20 | def validation_exception_handler(_, exc: RequestValidationError): 21 | return JSONResponse( 22 | status_code=status.HTTP_400_BAD_REQUEST, 23 | content={ 24 | "code": ErrorCode.REQUEST_PARAMS_ERROR.value, 25 | "message": f"request params error: {exc.body}" 26 | }, 27 | ) 28 | 29 | @_app.exception_handler(APPBaseException) 30 | def validation_exception_handler(_, exc: APPBaseException): 31 | return JSONResponse( 32 | status_code=status.HTTP_200_OK, 33 | content={ 34 | "code": exc.code.value, 35 | "message": exc.message 36 | }, 37 | ) 38 | 39 | 40 | def register_blueprints(_app): 41 | from app import routers 42 | _app.include_router(routers.router, prefix="/v1/api/trigger") 43 | 44 | 45 | def run(host, port): 46 | _app = init_app() 47 | uvicorn.run(_app, port=port, host=host) 48 | -------------------------------------------------------------------------------- /banned_words.txt: -------------------------------------------------------------------------------- 1 | bloodbath 2 | belly button 3 | ovaries 4 | -backed 5 | patriotic 6 | bruises 7 | thick 8 | blood 9 | sensual 10 | deepfake 11 | titty 12 | tryphophobia 13 | rule34 14 | sexi 15 | mammaries 16 | -edge 17 | jinping 18 | without clothes on 19 | booba 20 | president xi 21 | veiny 22 | orgy 23 | seducing 24 | playboy 25 | legs spread 26 | sexualiz 27 | pleasure 28 | mp5 29 | honkey 30 | crucified 31 | hentai 32 | stripped 33 | wound 34 | shag 35 | vivisection 36 | behind bars 37 | tight white 38 | bloodshot 39 | skimpy 40 | bloody 41 | nude 42 | mommy milker 43 | making love 44 | tied up 45 | brown pudding 46 | visceral 47 | bathing 48 | labia 49 | risque 50 | feces 51 | big ass 52 | ahegao 53 | fascist 54 | jerk off king at pic 55 | mccurry 56 | sperm 57 | sensored 58 | disturbing 59 | busty 60 | sexy 61 | reticulum 62 | rear end 63 | cocaine 64 | pleasures 65 | crack 66 | kill 67 | warts 68 | slaughter 69 | lolita 70 | guts 71 | honkers 72 | dick 73 | dominatrix 74 | sexual 75 | zero clothes 76 | boobs 77 | vagina 78 | boudoir 79 | booty 80 | seductive 81 | porn 82 | cannibal 83 | seduction 84 | loli 85 | crucifixion 86 | bimbo 87 | negligee 88 | hemoglobin 89 | shit 90 | bunghole 91 | bosom 92 | vein 93 | coon 94 | infested 95 | penis 96 | wincest 97 | jail 98 | poop 99 | farts 100 | cleavage 101 | smut 102 | ballgag 103 | cannibalism 104 | torture 105 | waifu 106 | surgery 107 | inappropriate 108 | naughty 109 | intimate 110 | sultry 111 | belle delphine 112 | khorne 113 | explicit 114 | fuck 115 | see through 116 | breasts 117 | anus 118 | horny 119 | cutting 120 | gory 121 | camisole 122 | revealing clothing 123 | heroin 124 | censored 125 | barely dressed 126 | hitler 127 | badonkers 128 | girth 129 | nipple 130 | scantily clad 131 | cronenberg 132 | hardcore 133 | meth 134 | unclothed 135 | pus 136 | wearing nothing 137 | succubus 138 | erotic 139 | massacre 140 | flesh 141 | pinup 142 | infected 143 | vomit 144 | minge 145 | sakimichan 146 | wang 147 | decapitate 148 | phallus 149 | fart 150 | dong 151 | voluptuous 152 | jav 153 | see-through 154 | clunge 155 | large bust 156 | bra 157 | arrest 158 | sensuality 159 | shirtless 160 | no clothes 161 | knob 162 | xxx 163 | prophet mohammed 164 | suicide 165 | silenced 166 | crotch 167 | bodily fluids 168 | seductively 169 | incest 170 | twerk 171 | sexy female 172 | reproduce 173 | killing 174 | car crash 175 | kinbaku 176 | invisible clothes 177 | hooters 178 | sadist 179 | erected 180 | slave 181 | provocative 182 | teratoma 183 | naked 184 | full frontal 185 | lingerie 186 | oppai 187 | petite 188 | bleed 189 | no shirt 190 | brothel 191 | au naturel 192 | organs 193 | bare 194 | nazi 195 | with no shirt 196 | excrement 197 | thot 198 | zedong 199 | corpse 200 | shibari 201 | ass 202 | gruesome 203 | transparent 204 | arse 205 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | git pull 2 | docker build -t kunyu/midjourney-api:1.0 . 3 | -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | if [ "$1" = 'http' ]; then 5 | set -- python server.py 6 | fi 7 | 8 | if [ "$1" = 'bot' ]; then 9 | set -- python task_bot.py "$@" 10 | fi 11 | 12 | exec "$@" 13 | -------------------------------------------------------------------------------- /exceptions.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import Union 3 | 4 | 5 | class ErrorCode(Enum): 6 | MISS_REQUIRED_VARIABLE_ERROR = 11 7 | MAX_RETRY_ERROR = 12 8 | REQUEST_PARAMS_ERROR = 13 9 | BANNED_PROMPT_ERROR = 14 10 | QUEUE_FULL_ERROR = 15 11 | 12 | 13 | class SuccessCode(Enum): 14 | SUCCESS = 0 15 | 16 | 17 | ReturnCode = Union[ErrorCode, SuccessCode] 18 | 19 | 20 | class APPBaseException(Exception): 21 | code: ErrorCode 22 | message: str 23 | 24 | def __init__(self, message: str): 25 | super().__init__(message) 26 | self.message = message 27 | 28 | 29 | class MissRequiredVariableError(APPBaseException): 30 | """缺少必需变量""" 31 | code = ErrorCode.MISS_REQUIRED_VARIABLE_ERROR 32 | 33 | 34 | class MaxRetryError(APPBaseException): 35 | """请求最大重试错误""" 36 | code = ErrorCode.MAX_RETRY_ERROR 37 | 38 | 39 | class RequestParamsError(APPBaseException): 40 | """请求参数异常""" 41 | code = ErrorCode.REQUEST_PARAMS_ERROR 42 | 43 | 44 | class BannedPromptError(APPBaseException): 45 | """提示词被禁用""" 46 | code = ErrorCode.BANNED_PROMPT_ERROR 47 | 48 | 49 | class QueueFullError(APPBaseException): 50 | """队列已满""" 51 | code = ErrorCode.QUEUE_FULL_ERROR 52 | -------------------------------------------------------------------------------- /lib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yokonsan/midjourney-api/a5781c4e5123624e55623bb468f0f59046e643f0/lib/__init__.py -------------------------------------------------------------------------------- /lib/api/__init__.py: -------------------------------------------------------------------------------- 1 | from os import getenv 2 | 3 | from exceptions import MissRequiredVariableError 4 | 5 | GUILD_ID = getenv("GUILD_ID") 6 | CHANNEL_ID = getenv("CHANNEL_ID") 7 | USER_TOKEN = getenv("USER_TOKEN") 8 | CALLBACK_URL = getenv("CALLBACK_URL") 9 | PROXY_URL = getenv("PROXY_URL") 10 | 11 | DRAW_VERSION = getenv("DRAW_VERSION") 12 | 13 | if not all([GUILD_ID, CHANNEL_ID, USER_TOKEN, DRAW_VERSION]): 14 | raise MissRequiredVariableError( 15 | "Missing required environment variable: [GUILD_ID, CHANNEL_ID, USER_TOKEN, DRAW_VERSION]") 16 | -------------------------------------------------------------------------------- /lib/api/callback.py: -------------------------------------------------------------------------------- 1 | from os import getenv 2 | 3 | import aiohttp 4 | from loguru import logger 5 | 6 | from lib.api import CALLBACK_URL 7 | from util.fetch import fetch 8 | 9 | 10 | async def callback(data): 11 | logger.debug(f"callback data: {data}") 12 | if not CALLBACK_URL: 13 | return 14 | 15 | headers = {"Content-Type": "application/json"} 16 | async with aiohttp.ClientSession( 17 | timeout=aiohttp.ClientTimeout(total=30), 18 | headers=headers 19 | ) as session: 20 | await fetch(session, CALLBACK_URL, json=data) 21 | 22 | 23 | QUEUE_RELEASE_API = getenv("QUEUE_RELEASE_API") \ 24 | or "http://127.0.0.1:8062/v1/api/trigger/queue/release" 25 | 26 | 27 | async def queue_release(trigger_id: str): 28 | logger.debug(f"queue_release: {trigger_id}") 29 | 30 | headers = {"Content-Type": "application/json"} 31 | data = {"trigger_id": trigger_id} 32 | async with aiohttp.ClientSession( 33 | timeout=aiohttp.ClientTimeout(total=30), 34 | headers=headers 35 | ) as session: 36 | await fetch(session, QUEUE_RELEASE_API, json=data) 37 | -------------------------------------------------------------------------------- /lib/api/discord.py: -------------------------------------------------------------------------------- 1 | import json 2 | from enum import Enum 3 | from typing import Dict, Any, Union 4 | 5 | import aiohttp 6 | 7 | from lib.api import CHANNEL_ID, USER_TOKEN, GUILD_ID, DRAW_VERSION, PROXY_URL 8 | from util.fetch import fetch, fetch_json, FetchMethod 9 | 10 | TRIGGER_URL = "https://discord.com/api/v9/interactions" 11 | UPLOAD_ATTACHMENT_URL = f"https://discord.com/api/v9/channels/{CHANNEL_ID}/attachments" 12 | SEND_MESSAGE_URL = f"https://discord.com/api/v9/channels/{CHANNEL_ID}/messages" 13 | HEADERS = { 14 | "Content-Type": "application/json", 15 | "Authorization": USER_TOKEN 16 | } 17 | 18 | 19 | class TriggerType(str, Enum): 20 | generate = "generate" 21 | upscale = "upscale" 22 | variation = "variation" 23 | solo_variation = "solo_variation" 24 | solo_low_variation = "solo_low_variation" 25 | solo_high_variation = "solo_high_variation" 26 | max_upscale = "max_upscale" 27 | reset = "reset" 28 | describe = "describe" 29 | expand = "expand" 30 | zoomout = "zoomout" 31 | 32 | 33 | async def trigger(payload: Dict[str, Any]): 34 | async with aiohttp.ClientSession( 35 | timeout=aiohttp.ClientTimeout(total=30), 36 | headers=HEADERS 37 | ) as session: 38 | return await fetch(session, TRIGGER_URL, data=json.dumps(payload), proxy=PROXY_URL) 39 | 40 | 41 | async def upload_attachment( 42 | filename: str, file_size: int, image: bytes 43 | ) -> Union[Dict[str, Union[str, int]], None]: 44 | payload = { 45 | "files": [{ 46 | "filename": filename, 47 | "file_size": file_size, 48 | "id": "0" 49 | }] 50 | } 51 | async with aiohttp.ClientSession( 52 | timeout=aiohttp.ClientTimeout(total=30), 53 | headers=HEADERS 54 | ) as session: 55 | response = await fetch_json(session, UPLOAD_ATTACHMENT_URL, data=json.dumps(payload)) 56 | if not response or not response.get("attachments"): 57 | return None 58 | 59 | attachment = response["attachments"][0] 60 | 61 | response = await put_attachment(attachment.get("upload_url"), image) 62 | return attachment if response is not None else None 63 | 64 | 65 | async def put_attachment(url: str, image: bytes): 66 | headers = {"Content-Type": "image/png"} 67 | async with aiohttp.ClientSession( 68 | timeout=aiohttp.ClientTimeout(total=30), 69 | headers=headers 70 | ) as session: 71 | return await fetch(session, url, data=image, method=FetchMethod.put) 72 | 73 | 74 | async def send_attachment_message(upload_filename: str) -> Union[str, None]: 75 | payload = { 76 | "content": "", 77 | "nonce": "", 78 | "channel_id": "1105829904790065223", 79 | "type": 0, 80 | "sticker_ids": [], 81 | "attachments": [{ 82 | "id": "0", 83 | "filename": upload_filename.split("/")[-1], 84 | "uploaded_filename": upload_filename 85 | }] 86 | } 87 | async with aiohttp.ClientSession( 88 | timeout=aiohttp.ClientTimeout(total=30), 89 | headers=HEADERS 90 | ) as session: 91 | response = await fetch_json(session, SEND_MESSAGE_URL, data=json.dumps(payload)) 92 | if not response or not response.get("attachments"): 93 | return None 94 | 95 | attachment = response["attachments"][0] 96 | return attachment.get("url") 97 | 98 | 99 | def _trigger_payload(type_: int, data: Dict[str, Any], **kwargs) -> Dict[str, Any]: 100 | payload = { 101 | "type": type_, 102 | "application_id": "936929561302675456", 103 | "guild_id": GUILD_ID, 104 | "channel_id": CHANNEL_ID, 105 | "session_id": "cb06f61453064c0983f2adae2a88c223", 106 | "data": data 107 | } 108 | payload.update(kwargs) 109 | return payload 110 | 111 | 112 | async def generate(prompt: str, **kwargs): 113 | payload = _trigger_payload(2, { 114 | "version": DRAW_VERSION, 115 | "id": "938956540159881230", 116 | "name": "imagine", 117 | "type": 1, 118 | "options": [{ 119 | "type": 3, 120 | "name": "prompt", 121 | "value": prompt 122 | }], 123 | "attachments": [] 124 | }) 125 | return await trigger(payload) 126 | 127 | 128 | async def upscale(index: int, msg_id: str, msg_hash: str, **kwargs): 129 | kwargs = { 130 | "message_flags": 0, 131 | "message_id": msg_id, 132 | } 133 | payload = _trigger_payload(3, { 134 | "component_type": 2, 135 | "custom_id": f"MJ::JOB::upsample::{index}::{msg_hash}" 136 | }, **kwargs) 137 | return await trigger(payload) 138 | 139 | 140 | async def variation(index: int, msg_id: str, msg_hash: str, **kwargs): 141 | kwargs = { 142 | "message_flags": 0, 143 | "message_id": msg_id, 144 | } 145 | payload = _trigger_payload(3, { 146 | "component_type": 2, 147 | "custom_id": f"MJ::JOB::variation::{index}::{msg_hash}" 148 | }, **kwargs) 149 | return await trigger(payload) 150 | 151 | 152 | async def solo_variation(msg_id: str, msg_hash: str, **kwargs): 153 | kwargs = { 154 | "message_flags": 0, 155 | "message_id": msg_id, 156 | } 157 | payload = _trigger_payload(3, { 158 | "component_type": 2, 159 | "custom_id": f"MJ::JOB::variation::1::{msg_hash}::SOLO" 160 | }, **kwargs) 161 | return await trigger(payload) 162 | 163 | async def solo_low_variation(msg_id: str, msg_hash: str, **kwargs): 164 | kwargs = { 165 | "message_flags": 0, 166 | "message_id": msg_id, 167 | } 168 | payload = _trigger_payload(3, { 169 | "component_type": 2, 170 | "custom_id": f"MJ::JOB::low_variation::1::{msg_hash}::SOLO" 171 | }, **kwargs) 172 | return await trigger(payload) 173 | 174 | async def solo_high_variation(msg_id: str, msg_hash: str, **kwargs): 175 | kwargs = { 176 | "message_flags": 0, 177 | "message_id": msg_id, 178 | } 179 | payload = _trigger_payload(3, { 180 | "component_type": 2, 181 | "custom_id": f"MJ::JOB::high_variation::1::{msg_hash}::SOLO" 182 | }, **kwargs) 183 | return await trigger(payload) 184 | 185 | 186 | async def expand(msg_id: str, msg_hash: str, direction: str, **kwargs): 187 | kwargs = { 188 | "message_flags": 0, 189 | "message_id": msg_id, 190 | } 191 | payload = _trigger_payload(3, { 192 | "component_type": 2, 193 | "custom_id": f"MJ::JOB::pan_{direction}::1::{msg_hash}::SOLO" 194 | }, **kwargs) 195 | return await trigger(payload) 196 | 197 | 198 | async def zoomout(msg_id: str, msg_hash: str, zoomout: int, **kwargs): 199 | kwargs = { 200 | "message_flags": 0, 201 | "message_id": msg_id, 202 | } 203 | payload = _trigger_payload(3, { 204 | "component_type": 2, 205 | "custom_id": f"MJ::Outpaint::{zoomout}::1::{msg_hash}::SOLO" 206 | }, **kwargs) 207 | return await trigger(payload) 208 | 209 | 210 | async def max_upscale(msg_id: str, msg_hash: str, **kwargs): 211 | kwargs = { 212 | "message_flags": 0, 213 | "message_id": msg_id, 214 | } 215 | payload = _trigger_payload(3, { 216 | "component_type": 2, 217 | "custom_id": f"MJ::JOB::upsample_max::1::{msg_hash}::SOLO" 218 | }, **kwargs) 219 | return await trigger(payload) 220 | 221 | 222 | async def reset(msg_id: str, msg_hash: str, **kwargs): 223 | kwargs = { 224 | "message_flags": 0, 225 | "message_id": msg_id, 226 | } 227 | payload = _trigger_payload(3, { 228 | "component_type": 2, 229 | "custom_id": f"MJ::JOB::reroll::0::{msg_hash}::SOLO" 230 | }, **kwargs) 231 | return await trigger(payload) 232 | 233 | 234 | async def describe(upload_filename: str, **kwargs): 235 | payload = _trigger_payload(2, { 236 | "version": DRAW_VERSION, 237 | "id": "1092492867185950852", 238 | "name": "describe", 239 | "type": 1, 240 | "options": [ 241 | { 242 | "type": 11, 243 | "name": "image", 244 | "value": 0 245 | } 246 | ], 247 | "attachments": [{ 248 | "id": "0", 249 | "filename": upload_filename.split("/")[-1], 250 | "uploaded_filename": upload_filename, 251 | }] 252 | }) 253 | return await trigger(payload) 254 | -------------------------------------------------------------------------------- /lib/prompt.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import aiofiles 4 | 5 | 6 | async def loads_banned_words(): 7 | prompt = set() 8 | filename = "banned_words.txt" 9 | async with aiofiles.open(filename, "r") as r: 10 | for line in await r.readlines(): 11 | prompt.add(line.strip()) 12 | 13 | return prompt 14 | 15 | BANNED_PROMPT = asyncio.run(loads_banned_words()) 16 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | discord.py==2.2.3 2 | fastapi==0.95 3 | python-dotenv==1.0.0 4 | uvicorn==0.22.0 5 | pydantic~=1.10.7 6 | aiohttp~=3.8.4 7 | loguru==0.7.0 8 | aiofiles==23.1.0 9 | python-multipart==0.0.6 -------------------------------------------------------------------------------- /server.py: -------------------------------------------------------------------------------- 1 | import __init__ # noqa 2 | from app import server 3 | 4 | api_app = server.init_app() 5 | 6 | 7 | if __name__ == '__main__': 8 | server.run("0.0.0.0", 8062) 9 | -------------------------------------------------------------------------------- /start.sh: -------------------------------------------------------------------------------- 1 | docker rm -f mj-server mj-bot 2 | #docker rmi kunyu/midjourney-api:1.0 3 | 4 | docker network create mjapi 5 | docker run -d --net mjapi --name mj-server -p 8062:8062 \ 6 | -e TZ=Asia/Shanghai \ 7 | -e LOG_LEVEL=DEBUG \ 8 | -e USER_TOKEN="" \ 9 | -e GUILD_ID="" \ 10 | -e CHANNEL_ID="" \ 11 | -e CONCUR_SIZE=3 \ 12 | -e WAIT_SIZE=10 \ 13 | kunyu/midjourney-api:1.0 14 | 15 | docker run -d --net mjapi --name mj-bot \ 16 | -e TZ=Asia/Shanghai \ 17 | -e LOG_LEVEL=DEBUG \ 18 | -e USER_TOKEN="" \ 19 | -e BOT_TOKEN="" \ 20 | -e GUILD_ID="" \ 21 | -e CHANNEL_ID="" \ 22 | -e CALLBACK_URL="" \ 23 | -e QUEUE_RELEASE_API="http://mj-server:8062/v1/api/trigger/queue/release" \ 24 | kunyu/midjourney-api:1.0 bot 25 | -------------------------------------------------------------------------------- /task/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yokonsan/midjourney-api/a5781c4e5123624e55623bb468f0f59046e643f0/task/__init__.py -------------------------------------------------------------------------------- /task/bot/__init__.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class TriggerStatus(Enum): 5 | start = "start" # 首次触发 MessageType.chat_input_command 6 | generating = "generating" # 生成中 7 | end = "end" # 生成结束 MessageType.default 8 | error = "error" # 生成错误 9 | banned = "banned" # 提示词被禁 10 | 11 | text = "text" # 文本内容:describe 12 | 13 | verify = "verify" # 需人工验证 14 | -------------------------------------------------------------------------------- /task/bot/_typing.py: -------------------------------------------------------------------------------- 1 | from typing import TypedDict, List, Union 2 | 3 | 4 | class Attachment(TypedDict): 5 | id: int 6 | url: str 7 | proxy_url: str 8 | filename: str 9 | content_type: str 10 | width: int 11 | height: int 12 | size: int 13 | ephemeral: bool 14 | 15 | 16 | class EmbedsImage(TypedDict): 17 | url: str 18 | proxy_url: str 19 | 20 | 21 | class Embed(TypedDict): 22 | type: str 23 | description: str 24 | image: EmbedsImage 25 | 26 | 27 | class CallbackData(TypedDict): 28 | type: str 29 | id: int 30 | content: str 31 | attachments: List[Attachment] 32 | embeds: List[Embed] 33 | 34 | trigger_id: str 35 | -------------------------------------------------------------------------------- /task/bot/handler.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import re 3 | from typing import Dict, Union, Any 4 | 5 | from discord import Message 6 | 7 | from app.handler import PROMPT_PREFIX, PROMPT_SUFFIX 8 | from lib.api.callback import queue_release, callback 9 | from task.bot._typing import CallbackData, Attachment, Embed 10 | 11 | TRIGGER_ID_PATTERN = f"{PROMPT_PREFIX}(\w+?){PROMPT_SUFFIX}" # 消息 ID 正则 12 | 13 | TEMP_MAP: Dict[str, bool] = {} # 临时存储消息流转信息 14 | 15 | 16 | def get_temp(trigger_id: str): 17 | return TEMP_MAP.get(trigger_id) 18 | 19 | 20 | def set_temp(trigger_id: str): 21 | TEMP_MAP[trigger_id] = True 22 | 23 | 24 | def pop_temp(trigger_id: str): 25 | asyncio.get_event_loop().create_task(queue_release(trigger_id)) 26 | try: 27 | TEMP_MAP.pop(trigger_id) 28 | except KeyError: 29 | pass 30 | 31 | 32 | def match_trigger_id(content: str) -> Union[str, None]: 33 | match = re.findall(TRIGGER_ID_PATTERN, content) 34 | return match[0] if match else None 35 | 36 | 37 | async def callback_trigger(trigger_id: str, trigger_status: str, message: Message): 38 | await callback(CallbackData( 39 | type=trigger_status, 40 | id=message.id, 41 | content=message.content, 42 | attachments=[ 43 | Attachment(**attachment.to_dict()) 44 | for attachment in message.attachments 45 | ], 46 | embeds=[], 47 | trigger_id=trigger_id, 48 | )) 49 | 50 | 51 | async def callback_describe(trigger_status: str, message: Message, embed: Dict[str, Any]): 52 | url = embed.get("image", {}).get("url") 53 | trigger_id = url.split("/")[-1].split(".")[0] 54 | 55 | await callback(CallbackData( 56 | type=trigger_status, 57 | id=message.id, 58 | content=message.content, 59 | attachments=[], 60 | embeds=[ 61 | Embed(**embed) 62 | ], 63 | trigger_id=trigger_id, 64 | )) 65 | return trigger_id 66 | -------------------------------------------------------------------------------- /task/bot/listener.py: -------------------------------------------------------------------------------- 1 | from discord import Intents, Message 2 | from discord.ext import commands 3 | from loguru import logger 4 | from lib.api import PROXY_URL 5 | from task.bot import TriggerStatus 6 | from task.bot.handler import ( 7 | match_trigger_id, 8 | set_temp, 9 | pop_temp, 10 | get_temp, 11 | callback_trigger, 12 | callback_describe 13 | ) 14 | 15 | intents = Intents.default() 16 | intents.message_content = True 17 | bot = commands.Bot(command_prefix="", intents=intents, proxy=PROXY_URL) 18 | 19 | 20 | @bot.event 21 | async def on_ready(): 22 | logger.success(f"Logged in as {bot.user} (ID: {bot.user.id})") 23 | 24 | 25 | @bot.event 26 | async def on_message(message: Message): 27 | if message.author.id != 936929561302675456: 28 | return 29 | 30 | logger.debug(f"on_message: {message.content}") 31 | logger.debug(f"on_message embeds: {message.embeds[0].to_dict() if message.embeds else message.embeds}") 32 | content = message.content 33 | trigger_id = match_trigger_id(content) 34 | if not trigger_id: 35 | return 36 | 37 | if content.find("Waiting to start") != -1: 38 | trigger_status = TriggerStatus.start.value 39 | set_temp(trigger_id) 40 | elif content.find("(Stopped)") != -1: 41 | trigger_status = TriggerStatus.error.value 42 | pop_temp(trigger_id) 43 | else: 44 | trigger_status = TriggerStatus.end.value 45 | pop_temp(trigger_id) 46 | 47 | await callback_trigger(trigger_id, trigger_status, message) 48 | 49 | 50 | @bot.event 51 | async def on_message_edit(_: Message, after: Message): 52 | if after.author.id != 936929561302675456: 53 | return 54 | 55 | logger.debug(f"on_message_edit: {after.content}") 56 | if after.embeds: 57 | embed = after.embeds[0] 58 | if not (embed.image.width and embed.image.height): 59 | return 60 | 61 | embed = embed.to_dict() 62 | logger.debug(f"on_message_edit embeds: {embed}") 63 | trigger_status = TriggerStatus.text.value 64 | trigger_id = await callback_describe(trigger_status, after, embed) 65 | pop_temp(trigger_id) 66 | return 67 | 68 | trigger_id = match_trigger_id(after.content) 69 | if not trigger_id: 70 | return 71 | 72 | if after.webhook_id != "": 73 | await callback_trigger(trigger_id, TriggerStatus.generating.value, after) 74 | 75 | 76 | @bot.event 77 | async def on_message_delete(message: Message): 78 | if message.author.id != 936929561302675456: 79 | return 80 | 81 | trigger_id = match_trigger_id(message.content) 82 | if not trigger_id: 83 | return 84 | 85 | if get_temp(trigger_id) is None: 86 | return 87 | 88 | logger.debug(f"on_message_delete: {message.content}") 89 | logger.warning(f"sensitive content: {message.content}") 90 | trigger_status = TriggerStatus.banned.value 91 | pop_temp(trigger_id) 92 | await callback_trigger(trigger_id, trigger_status, message) 93 | -------------------------------------------------------------------------------- /task_bot.py: -------------------------------------------------------------------------------- 1 | from os import getenv 2 | 3 | import __init__ # noqa 4 | from exceptions import MissRequiredVariableError 5 | from task.bot.listener import bot 6 | 7 | BOT_TOKEN = getenv("BOT_TOKEN") 8 | if not BOT_TOKEN: 9 | raise MissRequiredVariableError("Missing required environment variable: [BOT_TOKEN]") 10 | 11 | 12 | if __name__ == '__main__': 13 | bot.run(BOT_TOKEN) 14 | -------------------------------------------------------------------------------- /util/_queue.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from collections import deque 3 | from os import getenv 4 | from typing import ParamSpec, Callable, Any, Dict, List, Deque 5 | 6 | from loguru import logger 7 | 8 | from exceptions import QueueFullError 9 | 10 | P = ParamSpec("P") 11 | 12 | 13 | class Task: 14 | def __init__( 15 | self, func: Callable[P, Any], *args: P.args, **kwargs: P.kwargs 16 | ) -> None: 17 | self.func = func 18 | self.args = args 19 | self.kwargs = kwargs 20 | 21 | async def __call__(self) -> None: 22 | await self.func(*self.args, **self.kwargs) 23 | 24 | def __repr__(self) -> str: 25 | return f"{self.func.__name__}({self.args}, {self.kwargs})" 26 | 27 | 28 | class TaskQueue: 29 | def __init__(self, concur_size: int, wait_size: int) -> None: 30 | self._concur_size = concur_size 31 | self._wait_size = wait_size 32 | self._wait_queue: Deque[Dict[str, Task]] = deque() 33 | self._concur_queue: List[str] = [] 34 | 35 | def put( 36 | self, 37 | _trigger_id: str, 38 | func: Callable[P, Any], 39 | *args: P.args, 40 | **kwargs: P.kwargs 41 | ) -> None: 42 | if len(self._wait_queue) >= self._wait_size: 43 | raise QueueFullError(f"Task queue is full: {self._wait_size}") 44 | 45 | self._wait_queue.append({ 46 | _trigger_id: Task(func, *args, **kwargs) 47 | }) 48 | while self._wait_queue and len(self._concur_queue) < self._concur_size: 49 | self._exec() 50 | 51 | def pop(self, _trigger_id: str) -> None: 52 | self._concur_queue.remove(_trigger_id) 53 | if self._wait_queue: 54 | self._exec() 55 | 56 | def _exec(self): 57 | key, task = self._wait_queue.popleft().popitem() 58 | self._concur_queue.append(key) 59 | 60 | logger.debug(f"Task[{key}] start execution: {task}") 61 | loop = asyncio.get_running_loop() 62 | tsk = loop.create_task(task()) 63 | # tsk.add_done_callback( 64 | # lambda t: print(t.result()) 65 | # ) # todo 66 | 67 | def concur_size(self): 68 | return self._concur_size 69 | 70 | def wait_size(self): 71 | return self._wait_size 72 | 73 | def clear_wait(self): 74 | self._wait_queue.clear() 75 | 76 | def clear_concur(self): 77 | self._concur_queue.clear() 78 | 79 | 80 | taskqueue = TaskQueue( 81 | int(getenv("CONCUR_SIZE") or 9999), 82 | int(getenv("WAIT_SIZE") or 9999), 83 | ) 84 | -------------------------------------------------------------------------------- /util/fetch.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Callable, Coroutine, Any, TypeVar, Union, Dict 3 | 4 | from aiohttp import ClientError, ClientSession, hdrs 5 | from loguru import logger 6 | 7 | from exceptions import MaxRetryError 8 | 9 | T = TypeVar("T") 10 | 11 | 12 | class MaxRetry: 13 | """重试装饰器""" 14 | def __init__(self, max_retry: int = 0): 15 | self.max_retry = max_retry 16 | 17 | def __call__(self, connect_once: Callable[..., Coroutine[Any, Any, T]]) -> Callable[..., Coroutine[Any, Any, T]]: 18 | async def connect_n_times(*args: Any, **kwargs: Any) -> T: 19 | retry = self.max_retry + 1 20 | while retry: 21 | try: 22 | return await connect_once(*args, **kwargs) 23 | except ClientError as e: 24 | await asyncio.sleep(1) 25 | logger.warning(f"请求失败({e.__class__.__name__}),正在重试,剩余 {retry - 1} 次") 26 | except asyncio.TimeoutError: 27 | logger.warning(f"请求超时,正在重试,剩余 {retry - 1} 次") 28 | finally: 29 | retry -= 1 30 | raise MaxRetryError("超出最大重试次数") 31 | 32 | return connect_n_times 33 | 34 | 35 | class FetchMethod: 36 | get = hdrs.METH_GET 37 | post = hdrs.METH_POST 38 | put = hdrs.METH_PUT 39 | 40 | 41 | @MaxRetry(2) 42 | async def fetch( 43 | session: ClientSession, 44 | url: str, 45 | method: str = FetchMethod.post, **kwargs 46 | ) -> Union[bool, None]: 47 | logger.debug(f"Fetch: {url}, {kwargs}") 48 | async with session.request(method, url, **kwargs) as resp: 49 | if not resp.ok: 50 | return None 51 | return True 52 | 53 | 54 | @MaxRetry(2) 55 | async def fetch_json( 56 | session: ClientSession, 57 | url: str, 58 | method: str = FetchMethod.post, **kwargs 59 | ) -> Union[Dict, None]: 60 | logger.debug(f"Fetch text: {url}") 61 | async with session.request(method, url, **kwargs) as resp: 62 | if not resp.ok: 63 | return None 64 | return await resp.json() 65 | --------------------------------------------------------------------------------