├── .github └── workflows │ └── docker-build.yml ├── .gitignore ├── .replit ├── .style.yapf ├── LICENSE ├── README.md ├── config ├── TicketConf-exp.json ├── config-exp.json └── log │ └── .placeholder ├── dockerfile ├── entrypoint.sh ├── main.py ├── main.sh ├── replit.nix ├── requirements.txt ├── screenshots ├── emoji_id.png ├── emoji_role_rules.png ├── help_cmd.png ├── hide_chc.png ├── kook_develop.png ├── role1.png ├── role2.png ├── role3.png ├── role_id.png ├── tk1.png ├── tk2.png ├── tk3.png └── tk4.png ├── utils ├── __init__.py ├── cmd │ ├── botStatus.py │ └── grantRoles.py ├── file.py ├── gtime.py ├── help.py ├── kookApi.py └── myLog.py └── version.py /.github/workflows/docker-build.yml: -------------------------------------------------------------------------------- 1 | name: docker images cicd 2 | #触发器设置 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | # 项目任务,任务之间可以并行调度 10 | jobs: 11 | build: 12 | # 选择云端运行的环境 13 | runs-on: ubuntu-latest 14 | steps: 15 | #u ses代表使用一个模块,此处使用的是checkout模块,将github项目文件导入到当前环境中 16 | - uses: actions/checkout@v3 17 | # 使用with跟在后面来为前面的模块输入参数 18 | with: 19 | submodules: 'true' 20 | - name: Set up QEMU 21 | uses: docker/setup-qemu-action@v2 22 | - name: Set up Docker Buildx 23 | uses: docker/setup-buildx-action@v2 24 | - name: Login to DockerHub 25 | uses: docker/login-action@v2 26 | with: 27 | # 这里用到了github的secrets功能,避免账户和密码随仓库泄露 28 | username: ${{ secrets.DOCKER_USERNAME }} 29 | password: ${{ secrets.DOCKER_TOKEN }} 30 | # 设置当前的发行版本tag 31 | - name: Release version 32 | id: release_version 33 | run: | 34 | app_version=$(cat version.py |sed -ne "s/APP_VERSION\s=\s'v\(.*\)'/\1/gp") 35 | echo "app_version=$app_version" >> $GITHUB_ENV 36 | # 开始构建镜像 37 | - name: Build and push 38 | uses: docker/build-push-action@v2 39 | with: 40 | context: . 41 | file: dockerfile 42 | build-args: | 43 | GITHUB_TOKEN=${{ secrets.RELEASE_TOKEN }} 44 | platforms: | 45 | linux/amd64 46 | linux/arm64 47 | push: true 48 | # 指定用户/仓库名 49 | tags: | 50 | ${{ secrets.DOCKER_USERNAME }}/kook-ticket-bot:latest 51 | ${{ secrets.DOCKER_USERNAME }}/kook-ticket-bot:${{ env.app_version }} 52 | # 这里是通过md文件自动生成dockerhub描述的模块,也可以不需要 53 | - name: Docker Hub Description 54 | uses: peter-evans/dockerhub-description@v3 55 | with: 56 | username: ${{ secrets.DOCKER_USERNAME }} 57 | password: ${{ secrets.DOCKER_PASSWORD }} 58 | repository: ${{ secrets.DOCKER_USERNAME }}/kook-ticket-bot 59 | readme-filepath: ./README.md 60 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # config file 2 | config.json 3 | TicketConf.json 4 | # old version log.file 5 | idsave_1.txt 6 | idsave_2.txt 7 | idsave_3.txt 8 | # log file 9 | __pycache__/ 10 | *.log -------------------------------------------------------------------------------- /.replit: -------------------------------------------------------------------------------- 1 | 2 | # The primary language of the repl. There can be others, though! 3 | language = "python3" 4 | entrypoint = "main.py" 5 | # A list of globs that specify which files and directories should 6 | # be hidden in the workspace. 7 | hidden = ["venv", ".config", "**/__pycache__", "**/.mypy_cache", "**/*.pyc"] 8 | 9 | # Specifies which nix channel to use when building the environment. 10 | [nix] 11 | channel = "stable-22_11" 12 | 13 | # The command to start the interpreter. 14 | [interpreter] 15 | [interpreter.command] 16 | args = [ 17 | "stderred", 18 | "--", 19 | "prybar-python310", 20 | "-q", 21 | "--ps1", 22 | "\u0001\u001b[33m\u0002\u0001\u001b[00m\u0002 ", 23 | "-i", 24 | ] 25 | env = { LD_LIBRARY_PATH = "$PYTHON_LD_LIBRARY_PATH" } 26 | 27 | [env] 28 | VIRTUAL_ENV = "/home/runner/${REPL_SLUG}/venv" 29 | PATH = "${VIRTUAL_ENV}/bin" 30 | PYTHONPATH = "$PYTHONHOME/lib/python3.10:${VIRTUAL_ENV}/lib/python3.10/site-packages" 31 | REPLIT_POETRY_PYPI_REPOSITORY = "https://package-proxy.replit.com/pypi/" 32 | MPLBACKEND = "TkAgg" 33 | POETRY_CACHE_DIR = "${HOME}/${REPL_SLUG}/.cache/pypoetry" 34 | TZ = "Asia/Shanghai" 35 | 36 | # Enable unit tests. This is only supported for a few languages. 37 | [unitTest] 38 | language = "python3" 39 | 40 | # Add a debugger! 41 | [debugger] 42 | support = true 43 | 44 | # How to start the debugger. 45 | [debugger.interactive] 46 | transport = "localhost:0" 47 | startCommand = ["dap-python", "main.py"] 48 | 49 | # How to communicate with the debugger. 50 | [debugger.interactive.integratedAdapter] 51 | dapTcpAddress = "localhost:0" 52 | 53 | # How to tell the debugger to start a debugging session. 54 | [debugger.interactive.initializeMessage] 55 | command = "initialize" 56 | type = "request" 57 | 58 | [debugger.interactive.initializeMessage.arguments] 59 | adapterID = "debugpy" 60 | clientID = "replit" 61 | clientName = "replit.com" 62 | columnsStartAt1 = true 63 | linesStartAt1 = true 64 | locale = "en-us" 65 | pathFormat = "path" 66 | supportsInvalidatedEvent = true 67 | supportsProgressReporting = true 68 | supportsRunInTerminalRequest = true 69 | supportsVariablePaging = true 70 | supportsVariableType = true 71 | 72 | # How to tell the debugger to start the debuggee application. 73 | [debugger.interactive.launchMessage] 74 | command = "attach" 75 | type = "request" 76 | 77 | [debugger.interactive.launchMessage.arguments] 78 | logging = {} 79 | 80 | # Configures the packager. 81 | [packager] 82 | language = "python3" 83 | ignoredPackages = ["unit_tests"] 84 | 85 | [packager.features] 86 | enabledForHosting = false 87 | # Enable searching packages from the sidebar. 88 | packageSearch = true 89 | # Enable guessing what packages are needed from the code. 90 | guessImports = true 91 | 92 | # These are the files that need to be preserved when this 93 | # language template is used as the base language template 94 | # for Python repos imported from GitHub 95 | [gitHubImport] 96 | requiredFiles = [".replit", "replit.nix", ".config", "venv"] 97 | 98 | [languages] 99 | 100 | [languages.python3] 101 | pattern = "**/*.py" 102 | 103 | [languages.python3.languageServer] 104 | start = "pylsp" 105 | -------------------------------------------------------------------------------- /.style.yapf: -------------------------------------------------------------------------------- 1 | [style] 2 | based_on_style = pep8 3 | column_limit = 120 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Kook-Ticket-Bot 2 | 3 | ## 1.说明 4 | 5 | ![commit](https://img.shields.io/github/last-commit/musnows/Kook-Ticket-Bot) ![release](https://img.shields.io/github/v/release/musnows/Kook-Ticket-Bot) [![khl server](https://www.kaiheila.cn/api/v3/badge/guild?guild_id=3986996654014459&style=0)](https://kook.top/gpbTwZ) 6 | 7 | A ticket bot for KOOK, **自托管**表单/工单系统机器人 8 | 9 | 工作流程 10 | * 当用户B点击卡片消息的按钮后,创建一个只有用户B可见的文字频道 11 | * Bot会自动在该临时频道发送一条消息,并`@用户B` 和处理表单的 `@管理员` 12 | * 当处理完毕后,点击`关闭`按钮,Bot会删除该文字频道 13 | * 文字频道删除后,Bot会给预先`设置好的log频道`和`开启ticket的用户`发送一条记录信息,并在服务器后端保存该ticket的聊天记录; 14 | * 管理员可以使用`/tkcm`命令,指定ticket编号对该工单发表评论 15 | 16 | 附加功能 17 | * 通过表情回应给用户添加对应角色 18 | * 设置Bot动态 `游戏/音乐` 19 | 20 | 本README尽量详细,争取让没有写过python代码,但了解基本编程环境搭建的用户能配置成功并正常使用bot! 21 | 22 | > 无须服务器和环境搭建,在replit上部署本bot![WIKI教程](https://github.com/musnows/Kook-Ticket-Bot/wiki)。由于replit策略变动,此教程内容已失效。 23 | 24 | 如果您对本README还有完善的建议,十分欢迎您[加入KOOK帮助频道](https://kook.top/gpbTwZ)与我联系,亦或者在仓库提出issue。 25 | 26 | ## 2.帮助命令 27 | 28 | Bot的帮助命令为 `/tkhelp`。 29 | 30 | ![helpCmd](./screenshots/help_cmd.png) 31 | 32 | 主要配置项均在配置文件中,后文有提及。 33 | 34 | ## 3.安装和运行 35 | 36 | ### 3.1 Python 运行 37 | 38 | 使用本机器人之前,请先确认您的python版本高于`3.9`, 安装以下依赖项 39 | 40 | ``` 41 | pip3 install -r requirements.txt 42 | ``` 43 | 44 | 完成下方的配置后,就可以运行bot了。 45 | 46 | ``` 47 | python3 main.py 48 | ``` 49 | 50 | 如果是linux系统需要bot后台运行,使用如下命令 51 | 52 | ``` 53 | nohup python3 -u main.py >> ./log/bot.log 2>&1 & 54 | ``` 55 | 56 | ### 3.2 Docker 运行 57 | 58 | 提供了一个docker容器供不太熟悉Linux命令行的用户使用,镜像名字为`musnows/kook-ticket-bot`。 59 | 60 | ``` 61 | docker pull musnows/kook-ticket-bot:latest 62 | ``` 63 | 64 | 在使用镜像创建容器之前,您需要参考后文的教程,在本地准备一个存放配置文件的目录(包括`config.json`和`TicketConf.json`,并将该目录映射到容器内的`/app/config`中。 65 | 66 | 示例运行命令如下 67 | 68 | ```bash 69 | docker run -it -d \ 70 | -v 本地配置文件路径:/app/config \ 71 | --name=kook-ticket-bot \ 72 | musnows/kook-ticket-bot:latest 73 | ``` 74 | 75 | 如果您需要使用Webhook的方式对接机器人到kook,则还需要暴露容器内的40000端口。另外,您还需要开启宿主机服务器上该端口的防火墙,以保证外网可以正常连接这个端口。 76 | 77 | ```bash 78 | docker run -it -d \ 79 | -v 本地配置文件路径:/app/config \ 80 | -p 40000:40000 \ 81 | --name=kook-ticket-bot \ 82 | musnows/kook-ticket-bot:latest 83 | ``` 84 | 85 | 如果您在创建docker容器后容器无法运行,且`docker logs 容器ID`的报错如下所示,这代表容器内的python没有办法通过DNS获取到kook服务器的IP地址。 86 | 87 | ``` 88 | aiohttp.client_exceptions.ClientConnectorError: Cannot connect to host www.kookapp.cn:443 ssl:default [Name or service not known] 89 | CRITICAL:main.py:loading_channel:763 | [BOT.START] 获取频道失败,请检查TicketConf文件中的debug_channel和log_channel 90 | ``` 91 | 92 | 一般这种情况都是DNS的配置问题,可以在run的时候加上如下命令设置DNS为字节跳动的公共DNS服务器。 93 | 94 | ``` 95 | docker run -it -d \ 96 | -v 本地配置文件路径:/app/config \ 97 | -p 40000:40000 \ 98 | --dns 180.184.1.1 \ 99 | --name=kook-ticket-bot \ 100 | musnows/kook-ticket-bot:latest 101 | ``` 102 | 103 | 使用run启动容器后,请使用`docker ps`检查机器人的容器是否正常运行,并使用如下命令查看日志,判断机器人是否正常启动。如果机器人的容器没有终止,且日志没有报错,那就可以去kook频道中尝试使用`/tkhelp`帮助命令呼出机器人了。 104 | 105 | ``` 106 | docker logs kook-ticket-bot 107 | ``` 108 | 109 | 日志中出现如下两行,即为机器人正常启动。其中`fetch_public_channel success`为机器人启动成功标志。 110 | 111 | ``` 112 | [24-07-20 19:28:16] INFO:main.py::771 | [BOT] Start at 24-07-20 19:28:16 113 | [24-07-20 19:28:16] INFO:main.py:loading_channel:758 | [BOT.START] fetch_public_channel success 114 | ``` 115 | 116 | ## 4.Config 配置项(必看) 117 | 118 | 因为bot开机的时候就会打开下面的文件,若缺少字段,会影响bot的正常运行; 119 | 120 | 目前在 [utils/file.py](./utils/file.py) 的底部打开了所有的**配置文件**,并添加了 `create_log_file()` 函数来自动创建不存在的数据文件。 121 | 122 | 以下README中对配置文件的示例仅供参考,若运行后出现了自动创建文件失败的报错,请采用REAMDE中的描述手动创建配置文件! 123 | 124 | ### 4.1 机器人 token 配置 125 | 126 | 在 `./config` 路径中添加`config.json`,并在里面填入[config-exp.json](./config/config-exp.json)的内容来初始化你的Bot。这些信息请在kook的[开发者后台-应用](https://developer.kookapp.cn/app/index)中获取。 127 | 128 | ```json 129 | { 130 | "token":"bot webhook token", 131 | "verify_token":"bot webhook verify token", 132 | "encrypt":"bot webhook encrypt token", 133 | "ws": true 134 | } 135 | ``` 136 | 137 | 使用webhook方式会开启一个回调地址,该操作需要有`公网ip`的机器才能进行 138 | 139 | * 如果你的机器人部署在无法外网访问(无公网ip)的机器上,请采用websocket链接方式; 140 | * 如果你的机器人在云服务器上部署,可以采用webhook或websocket方式; 141 | 142 | 配置文件`config.json`中的`"ws"`字段为websocket/webhook的选项,如果使用webhook,请将该配置项改为 `false`。 143 | 144 | * 修改本地配置后,记得在**kook机器人管理后台**修改机器人的链接配置; 145 | * webhook需要正确填写`"verify_token"`和`"encrypt"`配置项 146 | 147 | 如果采用webhook的连接方式,需要在kook机器人后台填写回调地址(Callback Url) 148 | 149 | ```bash 150 | # 默认情况下(记得开放服务器对应端口的防火墙) 151 | # 在main.py的开头,有机器人的初始化,内部有个port参数为端口 152 | http://公网ip:40000/khl-wh 153 | ``` 154 | 155 | 填写 `Callback Url` 之后,点击`重试`按钮,测试webhook是否正常。如果右下角显示`配置成功`,且没有红色字体的报错,那就是ok了! 156 | 157 | 如果多次点击重试后,依旧失败,请先尝试将url粘贴至浏览器,是否能正常访问。 158 | 159 | ``` 160 | 如果浏览器显示 161 | 405 method not allowed 162 | 那么代表你的url是没有问题的,可以正常在外网访问 163 | ``` 164 | 165 | 如果浏览器显示正常,但kook配置还是不行,请[加入KOOK帮助频道](https://kook.top/gpbTwZ)咨询! 166 | 167 | ### 4.2 TicketConfig 工单配置 168 | 169 | 在 `./config` 路径中新增`TicketConf.json`,并填入 [TicketConf-exp](./config/TicketConf-exp.json) 中的内容(也可以直接拷贝一份exp文件并重命名 170 | 171 | 请注意,配置文件里面的键值都不能修改! 172 | 173 | > 各类id获取办法:`kook设置-高级设置-打开开发者模式`;右键服务器头像,复制服务器id;右键用户头像即可复制用户id,右键频道/分组即可复制频道/分组id。 174 | > 175 | > ![kook开启开发者模式](./screenshots/kook_develop.png) 176 | 177 | ticket机器人需要您创建一个**对全体成员不可见**的隐藏分组,设置该分组权限为`@全体成员->分组不可见`来隐藏;并给管理员角色设置权限,让管理员能看到这个分组。 178 | 179 | ![设置隐藏分组](./screenshots/hide_chc.png) 180 | 181 | `admin_role`中的管理员角色,即为机器人发送的ticket消息中会`@`的角色组;且只有拥有管理员身份组的用户,才能`关闭ticket/给ticket写评论`。 182 | 183 | bot发送附带关闭按钮的卡片 184 | 185 | `"channel_id"` 字段机器人自己会填写,不需要自己填 186 | 187 | #### 关于命令权限问题 188 | 189 | 只有拥有`admin_role`中角色的用户才能操作bot的管理命令。 190 | 191 | 举例:服务器有个`摸鱼`角色,如果你想让**张三**可以操作bot的管理命令,那就需要给**张三**添加上`摸鱼`角色,并进入服务器的设置-角色管理-右键`摸鱼`角色,复制角色id,并把这个id添加到`"admin_role"`中。 192 | 193 | 角色id获取 194 | 195 | 假设`摸鱼`的角色id为114514,那么添加了之后的 `TicketConf.json` 配置文件应该如下 196 | 197 | ```json 198 | { 199 | "guild_id":"ticket bot 所服务的服务器id", 200 | "ticket": { 201 | "admin_role": [ 202 | "114514" 203 | ], 204 | "category_id": "隐藏掉的频道分组id", 205 | "channel_id": {}, 206 | "log_channel": "用于发送ticket日志的文字频道id", 207 | "debug_channel": "用于发送bot出错信息的文字频道id" 208 | } 209 | } 210 | ``` 211 | 212 | 这样才能让拥有`摸鱼`角色的用户**张三**操作`/ticket`命令。 213 | 214 | 215 | #### 单频道管理员(管理员分工) 216 | 217 | > 注意:使用 `/aar` 命令设置的单频道管理员无法执行ticket命令 218 | 219 | 目前机器人新增了单频道管理员配置,源于不同频道的ticket按钮,可以通知不同的管理员用户 220 | 221 | * 频道A和B都有ticket按钮 222 | * 在频道A执行 `/aar @角色甲` 的命令,会将角色甲添加入频道A ticket的管理员中 223 | * 此时用户点击频道A的ticket按钮,机器人会 @全局管理员 (配置文件`TicketConf["ticket"]["admin_role"]`中的管理员) 和 @角色甲 224 | * 点击B的ticket按钮,只会 @全局管理员 225 | * 如果想添加**全局管理员**,命令为 `/aar @角色乙 -g` 226 | 227 | 如果不想要全局管理员,配置文件中`"admin_role"`可以留空。此时只有 `master_id` 用户可以执行机器人管理命令,添加单频道管理员。 228 | 229 | #### 工单超时 230 | 231 | `TicketConf`中的如下字段是工单超时时间,当一个工单频道超过配置的时间(单位:小时)没有发送过消息,就会将该工单频道**锁定**(用户无法发送消息,但依旧看得到工单频道。管理员不受影响) 232 | 233 | ~~~ 234 | "outdate":48 235 | ~~~ 236 | 237 | 目前实现了手动锁定工单,和重新激活工单的功能。 238 | 239 | ### 4.3 TicketLog 240 | 241 | > 新版本Log文件机器人会自动创建,以下的配置项只为留档参考,理论上无须手动创建文件。 242 | 243 | 在 `code/log` 路径中新增 `TicketLog.json`,并填入以下字段 244 | 245 | ```json 246 | { 247 | "TKnum": 0, 248 | "data": {}, 249 | "msg_pair": {}, 250 | "TKchannel": {}, 251 | "user_pair":{} 252 | } 253 | ``` 254 | 255 | * TKnum是ticket的编号计数,最高为8位数字,应该完全够用了 256 | * TKchannel是用于记录bot创建的ticket频道id,和ticket编号对应 257 | * msg_pair是一个键值对,用于记录bot在ticket频道发送的消息(关闭按钮),和ticket编号对应 258 | * data中是每一个编号的ticket的详细信息,包括开启时间、开启用户、关闭时间、关闭用户、管理员的评论等 259 | 260 | ### 4.4 TicketMsgLog 261 | 262 | 在 `code/log` 路径中新增 `TicketMsgLog.json`,并填入以下字段 263 | 264 | ```json 265 | { 266 | "TKMsgChannel": {}, 267 | "data": {} 268 | } 269 | ``` 270 | * TKMsgChannel是用于记录bot创建的ticket频道id,和ticket编号对应,用来判断ticket频道是否有过消息(避免出现没有发过消息就关闭ticket频道的情况) 271 | * data为消息记录,作为ticket频道的消息记录 272 | 273 | 为了保存聊天记录,还需要创建 `code/log/ticket` 文件夹(机器人会自动创建) 274 | 275 | bot会在ticket关闭后,按照编号,保存 `code/log/ticket/编号.json` 文件,并删除 `TicketMsgLog.json` 中 `data` 字段里面的内容。 276 | 277 | ---- 278 | 279 | #### 下面是ticket功能的示例图 280 | 281 | 用户先点击按钮,机器人会创建一个临时频道 282 | 283 | ticket发起 284 | 285 | 并在该频道内部发送一条消息,并at用户和管理员,附带一个只有管理员才能关闭的按钮 286 | 287 | bot发送附带关闭按钮的卡片 288 | 289 | ticket被关闭后,bot会向`TicketConf.json`中设置的log频道发送一张卡片 290 | 291 | bot发送log卡片 292 | 293 | 管理员用户可以使用`/tkcm`命令,给某个ticket添加备注信息,卡片消息会同步更新 294 | 295 | ``` 296 | /tkcm TICKET编号 备注内容 297 | 示例 298 | /tkcm 00000000 这是一个测试 299 | ``` 300 | 301 | tkcm 302 | 303 | ---- 304 | 305 | ### 4.5 emoji/role 上角色功能 306 | 307 | 这个功能的作用是根据一条消息的表情回应,给用户上对应的角色。类似于YY里的上马甲。 308 | 309 | > 如果你不需要这个功能,请将`TicketConf.json`中的`"emoji"`字段删除,这样可以节省机器人的性能 310 | 311 | 请确认您的bot角色拥有管理员权限,并处于其需要给予的角色之上。如图,TestBot只能给其他用户上在他下面的角色,否则Api会报错 `无权限` 312 | 313 | emoji_role_rules 314 | 315 | 要想使用本功能,请创建 `code/log/ColorID.json`文件,复制如下内容到其中(新版本后,机器人会自动创建此文件) 316 | 317 | ```json 318 | { 319 | "data":{} 320 | } 321 | ``` 322 | 323 | 并在 `code/TicketConf.json` 里面追加如下字段 324 | 325 | ```json 326 | "emoji": { 327 | "消息id": { 328 | "channel_id": "该消息的频道id", 329 | "data": {}, 330 | "msg_id": "消息id" 331 | } 332 | } 333 | ``` 334 | 335 | 随后要做的是,在`data`里面添加emoji和角色id的对照表 336 | 337 | > 角色ID获取:设置内开启开发者模式后,进入服务器后台,右键角色复制id; 338 | > 339 | > 表情ID获取: 340 | > * 在客户端内,选中表情后`ctrl+c`,即可复制出表情id 341 | > * 在bot的代码中,打印[add_reaction的event消息](https://github.com/musnows/Kook-Ticket-Bot/blob/296f3bf477b8d5530934464fc7f8489d18c65379/code/main.py#L447-L452)获取表情id 342 | 343 | 复制表情id 344 | 345 | 配置示例如下,左侧为表情,右侧为这个表情对应的角色id 346 | 347 | ```json 348 | "emoji": { 349 | "消息id": { 350 | "data": { 351 | "❤": "对应的角色id-1", 352 | "🐷": "对应的角色id-2", 353 | "💙": "对应的角色id-3", 354 | "👍": "0(对应的是全体成员的角色)", 355 | }, 356 | "msg_id": "消息id" 357 | } 358 | } 359 | ``` 360 | 361 | 如果你有多个消息(比如不同的角色逻辑),那就在后续追加字段 362 | 363 | ```json 364 | "emoji": { 365 | "消息id A": { 366 | "data": { 367 | "❤": "对应的角色id-1", 368 | "🐷": "对应的角色id-2", 369 | "💙": "对应的角色id-3", 370 | "👍": "0(对应的是全体成员的角色)", 371 | }, 372 | "msg_id": "消息id A" 373 | }, 374 | "消息id B": { 375 | "data": {}, 376 | "msg_id": "消息id B" 377 | } 378 | } 379 | ``` 380 | 381 | 如下,您需要自行编写一个对应的角色关系消息,然后右键复制该消息的消息id。 382 | 卡片消息可以用官方的 [卡片编辑器](https://www.kookapp.cn/tools/message-builder.html#/card) 编辑后发送 383 | 384 | 上角色的消息 385 | 386 | 配置后,bot会根据配置文件中的`emoji:角色`对照表,给用户上对应的角色 387 | 388 | bot上角色 389 | 390 | ### 4.6 gaming/singing 机器人在玩状态 391 | 392 | 这两个命令都是用于控制机器人在玩状态的。其中机器人的游戏状态已经写死了几个游戏。 393 | 394 | 使用如下代码,你可以创建一个你自己想要的游戏 395 | 396 | ```python 397 | import requests 398 | 399 | url = "https://www.kookapp.cn/api/v3/game/create" 400 | botoken = "机器人 websocket token" 401 | header={f'Authorization': f"Bot {botoken}"} 402 | params ={ 403 | "name":"游戏名", 404 | "icon":"游戏图标的url (可以将图片上传到kook后,点开大图,在右下角...处复制url)" 405 | } 406 | ret = requests.post(url,headers=header,data=params) 407 | print(ret) 408 | print(ret.text) # 返回值中有游戏的id 409 | ``` 410 | 411 | 在最后的输出结果中,会包含游戏的id。关于此api字段的解释见[官方文档](https://developer.kookapp.cn/doc/http/game#%E6%B7%BB%E5%8A%A0%E6%B8%B8%E6%88%8F) 412 | 413 | 假设新增的游戏id为`12345`,那么就需要在`gaming`所在函数中,添加如下代码 414 | 415 | ~~~python 416 | if game == 10: # 自己设定一个执行命令时需要的编号 417 | ret = await status_active_game(12345) # xxx游戏的id 418 | await msg.reply(f"{ret['message']},Bot上号xxx游戏啦!") 419 | ~~~ 420 | 421 | kook的在玩状态同步及其缓慢,请耐心等待。 422 | 423 | ## The end 424 | 425 | 有任何问题,请添加`issue`,或加入我的交流服务器与我联系 [kook邀请链接](https://kook.top/gpbTwZ) 426 | 427 | 如果你觉得本项目还不错,还请高抬贵手点个star✨,万般感谢! 428 | -------------------------------------------------------------------------------- /config/TicketConf-exp.json: -------------------------------------------------------------------------------- 1 | { 2 | "emoji": { 3 | "asdfadsfadf": { 4 | "data": { 5 | "❤": "3970687", 6 | "🐷": "2881825", 7 | "👍": "0", 8 | "💙": "2928540", 9 | "💚": "2932370" 10 | }, 11 | "msg_id": "如上emoji上角色功能对应的消息id(需要先发消息)" 12 | } 13 | }, 14 | "guild_id": "服务器id", 15 | "ticket": { 16 | "master_id":"机器人主管用户id,始终可以执行管理命令", 17 | "admin_role": [ 18 | "管理员角色id1", 19 | "管理员角色id2" 20 | ], 21 | "category_id": "隐藏的分组id", 22 | "channel_id": {}, 23 | "debug_channel": "用于发送debug信息的文字频道id", 24 | "log_channel": "用于发送ticket日志信息的文字频道id", 25 | "outdate":48 26 | } 27 | } -------------------------------------------------------------------------------- /config/config-exp.json: -------------------------------------------------------------------------------- 1 | { 2 | "token":"bot webhook/websocket token", 3 | "verify_token":"bot webhook verify token", 4 | "encrypt":"bot webhook encrypt token", 5 | "ws":false 6 | } -------------------------------------------------------------------------------- /config/log/.placeholder: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/musnows/Kook-Ticket-Bot/eab012496fbc9e727e0827c886b5d3d937e38de8/config/log/.placeholder -------------------------------------------------------------------------------- /dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10.6-slim 2 | 3 | COPY requirements.txt requirements.txt 4 | RUN pip install --upgrade pip 5 | RUN pip install --no-cache-dir -r requirements.txt 6 | # 本地测试的时候用镜像源安装pip包 7 | # RUN pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple 8 | 9 | ENV LANG="C.UTF-8" \ 10 | TZ="Asia/Shanghai" 11 | 12 | WORKDIR /app 13 | 14 | COPY . /app/ 15 | COPY ./config /app/config 16 | COPY entrypoint.sh /entrypoint.sh 17 | 18 | RUN chmod +x /entrypoint.sh 19 | 20 | VOLUME [ "/app/config" ] 21 | 22 | ENTRYPOINT ["/entrypoint.sh"] 23 | CMD ["python", "main.py"] -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | echo "[entrypoint] begin to run entrypoint.sh" 3 | 4 | # 如果日志目录不存在,则创建它 5 | if [ ! -d /app/config/log ]; then 6 | mkdir -p /app/config/log 7 | touch /app/config/log/bot.log 8 | echo "[entrypoint] create files and dir of /app/config/log/bot.log" 9 | fi 10 | 11 | echo "[entrypoint] return to run python process" 12 | # 执行主程序 13 | exec "$@" -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8: 2 | import json 3 | import asyncio 4 | import copy 5 | import time 6 | import traceback 7 | import os 8 | 9 | from khl import Bot, Cert, Message, EventTypes, Event, Channel 10 | from khl.card import CardMessage, Card, Module, Element, Types 11 | from utils import help 12 | from utils.myLog import _log 13 | from utils.gtime import get_time,get_time_stamp_from_str,get_time_str_from_stamp,get_time_stamp 14 | from utils.file import * 15 | from utils.kookApi import * 16 | from utils.cmd import botStatus 17 | 18 | # config是在utils.py中读取的,直接import就能使用 19 | bot = Bot(token=Botconf["token"]) # websocket 20 | """main bot""" 21 | if not Botconf["ws"]: # webhook 22 | bot = Bot( 23 | cert=Cert( 24 | token=Botconf["token"], 25 | verify_token=Botconf["verify_token"], 26 | encrypt_key=Botconf["encrypt"], 27 | ), 28 | port=40000, 29 | ) # webhook 30 | 31 | debug_ch: Channel 32 | """debug 日志频道""" 33 | log_ch: Channel 34 | """tikcet 日志频道""" 35 | GUILD_ID = TKconf["guild_id"] 36 | """服务器id""" 37 | # 如下是针对工单按钮event的几个变量 38 | OUTDATE_HOURS = TKconf["ticket"]['outdate'] 39 | """工单频道过期时间(单位:小时)""" 40 | class TicketBtn: 41 | """工单按钮event.value['type']""" 42 | OPEN = 'tk_open' 43 | """工单开启""" 44 | CLOSE = 'tk_close' 45 | """工单关闭""" 46 | REOPEN = 'tk_reopen' 47 | """工单重新激活""" 48 | LOCK = 'tk_lock' 49 | """工单锁定""" 50 | 51 | #################################################################################### 52 | 53 | 54 | # `/hello`指令,一般用于测试bot是否成功上线 55 | @bot.command(name="hello", case_sensitive=False) 56 | async def world(msg: Message): 57 | logging(msg) 58 | await msg.reply("world!") 59 | 60 | 61 | # TKhelp帮助命令 62 | @bot.command(name="TKhelp", case_sensitive=False) 63 | async def help_cmd(msg: Message): 64 | logging(msg) 65 | cm = CardMessage( 66 | Card( 67 | Module.Header("ticket机器人命令面板"), 68 | Module.Context(f"开机于:{start_time}"), 69 | Module.Divider(), 70 | Module.Section(Element.Text(help.help_text(), Types.Text.KMD)), 71 | ) 72 | ) 73 | await msg.reply(cm) 74 | 75 | 76 | ################################以下是给ticket功能的内容######################################## 77 | 78 | 79 | # 判断用户是否在管理员身份组里面 80 | async def user_in_admin_role(guild_id: str, user_id: str, channel_id=""): 81 | """channel_id 必须为 ticket命令所在频道的id,并非每个工单频道的id""" 82 | # 是master管理员,随便玩 83 | if user_id == TKconf["ticket"]["master_id"]: 84 | return True 85 | # 如果不是预先设置好的服务器直接返回错误,避免bot被邀请到其他服务器去 86 | if guild_id != GUILD_ID: 87 | return False 88 | # 通过服务器id和用户id获取用户在服务器中的身份组 89 | guild = await bot.client.fetch_guild(guild_id) 90 | user_roles = (await guild.fetch_user(user_id)).roles 91 | # 遍历用户的身份组,看看有没有管理员身份组id 92 | for ar in user_roles: 93 | # 判断是否在全局管理员中 94 | if str(ar) in TKconf["ticket"]["admin_role"]: 95 | return True 96 | # channel_id不为空,判断是否为ticket局部管理员 97 | if ( 98 | channel_id 99 | and str(ar) in TKconf["ticket"]["channel_id"][channel_id]["admin_role"] 100 | ): 101 | return True 102 | # 都不是 103 | return False 104 | 105 | 106 | # ticket系统,发送卡片消息 107 | @bot.command(name="ticket", case_sensitive=False) 108 | async def ticket(msg: Message): 109 | global TKconf 110 | try: 111 | if not logging(msg): return 112 | if await user_in_admin_role(msg.ctx.guild.id, msg.author_id): 113 | ch_id = msg.ctx.channel.id # 当前所处的频道id 114 | values = json.dumps( 115 | {"type": TicketBtn.OPEN, "channel_id": ch_id, "user_id": msg.author_id} 116 | ) 117 | # 发送消息 118 | send_msg = await msg.ctx.channel.send( 119 | CardMessage( 120 | Card( 121 | Module.Section( 122 | "请点击右侧按钮发起ticket", 123 | Element.Button( 124 | "ticket", value=values, click=Types.Click.RETURN_VAL 125 | ) 126 | ) 127 | ) 128 | ) 129 | ) 130 | if ch_id not in TKconf["ticket"]["channel_id"]: # 如果不在 131 | # 发送完毕消息,并将该频道插入此目录 132 | TKconf["ticket"]["channel_id"][ch_id] = { 133 | "msg_id": send_msg["msg_id"], 134 | "admin_role": [], 135 | } 136 | _log.info( 137 | f"[Add TKch] Au:{msg.author_id} ChID:{ch_id} MsgID:{send_msg['msg_id']}" 138 | ) 139 | else: 140 | old_msg = TKconf["ticket"]["channel_id"][ch_id] # 记录旧消息的id输出到日志 141 | TKconf["ticket"]["channel_id"][ch_id]["msg_id"] = send_msg["msg_id"] # 更新消息id 142 | _log.info( 143 | f"[Add TKch] Au:{msg.author_id} ChID:{ch_id} New_MsgID:{send_msg['msg_id']} Old:{old_msg}" 144 | ) 145 | 146 | # 保存到文件 147 | write_file(TKConfPath, TKconf) 148 | else: 149 | await msg.reply(f"您没有权限执行本命令!") 150 | except: 151 | _log.exception(f"Au:{msg.author_id} | ERR") 152 | await msg.reply(f"ERR! [{get_time()}] tkcm\n```\n{traceback.format_exc()}\n```") 153 | 154 | 155 | # ticket系统,对已完成ticket进行备注 156 | @bot.command(name="tkcm", case_sensitive=False) 157 | async def ticket_commit(msg: Message, tkno: str, *args): 158 | if not logging(msg): 159 | return 160 | if tkno == "": 161 | await msg.reply(f"请提供ticket的八位数编号,如 000000123") 162 | return 163 | elif args == (): 164 | await msg.reply(f"ticket 备注不得为空!") 165 | return 166 | try: 167 | global TKconf, TKlog 168 | if not (await user_in_admin_role(msg.ctx.guild.id, msg.author_id)): 169 | return await msg.reply(f"您没有权限执行本命令!") 170 | if tkno not in TKlog["data"]: 171 | return await msg.reply("您输入的ticket编号未在数据库中!") 172 | if "log_ch_msg_id" not in TKlog["data"][tkno]: # 工单还没有结束 173 | return await msg.reply("需要工单结束后,才能对其评论。") 174 | 175 | cmt = " ".join(args) # 备注信息 176 | TKlog["data"][tkno]["cmt"] = cmt 177 | TKlog["data"][tkno]["cmt_usr"] = msg.author_id 178 | cm = CardMessage() 179 | c = Card( 180 | Module.Header(f"工单 ticket.{tkno} 已备注"), 181 | Module.Context(f"信息更新于 {get_time()}"), 182 | Module.Divider(), 183 | ) 184 | text = f"开启时间: {TKlog['data'][tkno]['start_time']}\n" 185 | text += f"发起用户: (met){TKlog['data'][tkno]['usr_id']}(met)\n" 186 | text += f"结束时间: {TKlog['data'][tkno]['end_time']}\n" 187 | text += f"关闭用户: (met){TKlog['data'][tkno]['end_usr']}(met)\n" 188 | text += "\n" 189 | text += f"来自 (met){msg.author_id}(met) 的备注:\n> {cmt}" 190 | c.append(Module.Section(Element.Text(text, Types.Text.KMD))) 191 | cm.append(c) 192 | await upd_card( 193 | bot, TKlog["data"][tkno]["log_ch_msg_id"], cm, channel_type=msg.channel_type 194 | ) 195 | # 保存到文件 196 | write_file(TKlogPath, TKlog) 197 | await msg.reply(f"工单「{tkno}」备注成功!") 198 | _log.info(f"[Cmt.TK] Au:{msg.author_id} - TkID:{tkno} = {cmt}") 199 | except: 200 | _log.exception(f"Au:{msg.author_id} | ERR") 201 | err_str = f"ERR! [{get_time()}] tkcm\n```\n{traceback.format_exc()}\n```" 202 | await msg.reply(f"{err_str}") 203 | 204 | 205 | @bot.command(name="add_admin_role", aliases=["aar"], case_sensitive=False) 206 | async def ticket_admin_role_add(msg: Message, role="", *arg): 207 | if not logging(msg): 208 | return 209 | if role == "" or "(rol)" not in role: 210 | return await msg.reply("请提供需要添加的角色:`/aar @角色`") 211 | 212 | global TKconf 213 | try: 214 | is_global = "-g" in arg or "-G" in arg # 判断是否要添加到全局管理员中 215 | role_id = role.replace("(rol)", "") 216 | ch_id = msg.ctx.channel.id # 当前频道id 217 | if not (await user_in_admin_role(msg.ctx.guild.id, msg.author_id)): 218 | return await msg.reply(f"您没有权限执行本命令!") 219 | # 判断是否已经为全局管理员 220 | if role_id in TKconf["ticket"]["admin_role"]: 221 | return await msg.reply( 222 | "这个id已经在配置文件 `TKconf['ticket']['admin_role']`(全局管理员) 中啦!" 223 | ) 224 | # 判断是否添加为当前频道的管理员(当前频道是否已有ticket 225 | if not is_global and ch_id not in TKconf["ticket"]["channel_id"]: 226 | return await msg.reply( 227 | f"当前频道暂无ticket触发按钮,无法设置当前频道ticket的管理员\n若想设置全局管理员,请在命令末尾添加`-g`参数" 228 | ) 229 | 230 | # 获取这个服务器的已有角色 231 | guild_roles = await ( 232 | await bot.client.fetch_guild(msg.ctx.guild.id) 233 | ).fetch_roles() 234 | _log.info(f"guild roles: {guild_roles}") # 打印出来做debug 235 | # 遍历角色id,找有没有和这个角色相同的 236 | for r in guild_roles: 237 | # 找到了 238 | if int(role_id) == r.id: 239 | if is_global: # 添加到全局 240 | TKconf["ticket"]["admin_role"].append(role_id) 241 | await msg.reply(f"成功添加「{role_id}」为全局管理员") 242 | else: 243 | TKconf["ticket"]["channel_id"][ch_id]["admin_role"].append(role_id) 244 | await msg.reply(f"成功添加「{role_id}」为当前频道ticket的管理员") 245 | # 保存到文件 246 | write_file(TKConfPath, TKconf) 247 | _log.info( 248 | f"[ADD.ADMIN.ROLE] rid:{role_id} | add to TKconf [{is_global}]" 249 | ) 250 | return 251 | # 遍历没有找到,提示用户 252 | await msg.reply(f"添加错误,请确认您提交的是本服务器的角色id") 253 | except: 254 | _log.exception(f"Au:{msg.author_id} | ERR") 255 | err_str = f"ERR! [{get_time()}] tkcm\n```\n{traceback.format_exc()}\n```" 256 | await msg.reply(f"{err_str}") 257 | 258 | 259 | ######################################### ticket 监看 ############################################################### 260 | 261 | TicketOpenLock = asyncio.Lock() 262 | """开启工单锁""" 263 | TicketCloseLock = asyncio.Lock() 264 | """工单关闭锁""" 265 | 266 | async def ticket_open_event(b: Bot, e: Event): 267 | """监看工单系统(开启) 268 | - 相关api文档 https://developer.kaiheila.cn/doc/http/channel#%E5%88%9B%E5%BB%BA%E9%A2%91%E9%81%93 269 | """ 270 | # 判断是否为ticket申请频道的id(文字频道id) 271 | global TKconf, TKlog 272 | try: 273 | # e.body['target_id'] 是ticket按钮所在频道的id 274 | # 判断当前频道id是否在执行了ticket命令的频道中 275 | if e.body["target_id"] in TKconf["ticket"]["channel_id"]: 276 | loggingE(e, "TK.OPEN") 277 | # 如果用户已经在键值对里面了,提示,告知无法开启 278 | if e.body["user_id"] in TKlog["user_pair"]: 279 | ch = await bot.client.fetch_public_channel(e.body["target_id"]) 280 | no = TKlog["user_pair"][e.body["user_id"]] 281 | text = f"(met){e.body['user_id']}(met)\n您当前已开启了一个ticket,请在已有ticket频道中留言\n" 282 | text += f"(chn){TKlog['data'][no]['channel_id']}(chn)" 283 | cm = CardMessage( 284 | Card(Module.Section(Element.Text(text, Types.Text.KMD))) 285 | ) 286 | await ch.send(cm, temp_target_id=e.body["user_id"]) 287 | _log.info(f"C:{e.body['target_id']} | Au:{e.body['user_id']} | user in tkconf:{no}") 288 | return 289 | # 0.先尝试给这个用户发个信息,发不过去,就提示他 290 | try: 291 | open_usr = await bot.client.fetch_user(e.body["user_id"]) 292 | send_msg = await open_usr.send(f"您点击了ticket按钮,这是一个私信测试") # 发送给用户 293 | ret = await direct_msg_delete(send_msg["msg_id"]) # 删除这个消息 294 | _log.info(f"[TK.OPEN] pm msg send test success | {ret}") 295 | except Exception as result: 296 | if "无法" in str(result) or "屏蔽" in str(result): 297 | ch = await bot.client.fetch_public_channel(e.body["target_id"]) 298 | await ch.send( 299 | f"为了保证ticket记录的送达,使用ticket-bot前,需要您私聊一下机器人(私聊内容不限)", 300 | temp_target_id=e.body["user_id"], 301 | ) 302 | _log.error(f"ERR! [TK.OPEN] | Au:{e.body['user_id']} = {result}") 303 | else: 304 | raise result 305 | # 先获取工单的编号 306 | no = str(TKlog["TKnum"]).rjust(8, "0") 307 | TKlog["TKnum"] += 1 308 | # 1.创建一个以开启ticket用户昵称为名字的文字频道 309 | ret1 = await channel_create( 310 | e.body["guild_id"], 311 | TKconf["ticket"]["category_id"], 312 | f"{no} | {e.body['user_info']['username']}", 313 | ) 314 | # 2.先设置管理员角色的权限 315 | # 全局管理员 316 | for rol in TKconf["ticket"]["admin_role"]: 317 | # 在该频道创建一个角色权限 318 | await crole_create(ret1["data"]["id"], "role_id", rol) 319 | # 设置该频道的角色权限为可见 320 | await crole_update(ret1["data"]["id"], "role_id", rol, 2048) 321 | await asyncio.sleep(0.2) # 休息一会 避免超速 322 | # 频道管理员 323 | for rol in TKconf["ticket"]["channel_id"][e.body["target_id"]]["admin_role"]: 324 | # 在该频道创建一个角色权限 325 | await crole_create(ret1["data"]["id"], "role_id", rol) 326 | # 设置该频道的角色权限为可见 327 | await crole_update(ret1["data"]["id"], "role_id", rol, 2048) 328 | await asyncio.sleep(0.2) # 休息一会 避免超速 329 | 330 | # 3.设置该频道的用户权限(开启tk的用户) 331 | # 在该频道创建一个用户权限 332 | await crole_create(ret1["data"]["id"], "user_id", e.body["user_id"]) 333 | # 设置该频道的用户权限为可见 334 | await crole_update(ret1["data"]["id"], "user_id", e.body["user_id"], 2048) 335 | 336 | # 4.在创建出来的频道发送消息 337 | text = f"(met){e.body['user_id']}(met) 发起了帮助,请等待管理猿的回复\n" 338 | text += f"工单编号/ID:{no}\n" 339 | text += f"工单开启时间:{get_time()}\n" 340 | # 管理员角色id,修改配置文件中的admin_role部分 341 | for roles_id in TKconf["ticket"]["admin_role"]: 342 | text += f"(rol){roles_id}(rol) " 343 | for roles_id in TKconf["ticket"]["channel_id"][e.body["target_id"]][ 344 | "admin_role" 345 | ]: 346 | text += f"(rol){roles_id}(rol) " 347 | text += "\n" 348 | values_close = json.dumps({"type": TicketBtn.CLOSE, 349 | "channel_id": e.body["target_id"],"user_id": e.body["user_id"],}) 350 | values_lock = json.dumps({"type": TicketBtn.LOCK, 351 | "channel_id": e.body["target_id"],"user_id": e.body["user_id"],}) 352 | # 构造卡片 353 | cm = CardMessage() 354 | c1 = Card(Module.Section(Element.Text(text, Types.Text.KMD)),Module.Divider()) 355 | text = "帮助结束后,请点击下方“关闭”按钮关闭该ticket频道\n" 356 | text+= "或使用“锁定”功能,暂时锁定工单(可见,无法发言)" 357 | c1.append(Module.Section(Element.Text(text, Types.Text.KMD))) 358 | c1.append(Module.ActionGroup( 359 | Element.Button("关闭",value=values_close,click=Types.Click.RETURN_VAL,theme=Types.Theme.DANGER), 360 | Element.Button("锁定",value=values_lock,click=Types.Click.RETURN_VAL,theme=Types.Theme.WARNING) 361 | )) 362 | cm.append(c1) 363 | channel = await bot.client.fetch_public_channel(ret1["data"]["id"]) 364 | sent = await bot.client.send(channel, cm) 365 | 366 | # 5.发送消息完毕,记录消息信息 367 | TKlog["data"][no] = { 368 | "usr_id":e.body["user_id"], 369 | "usr_info":f"{e.body['user_info']['username']}#{e.body['user_info']['identify_num']}", 370 | "msg_id": sent["msg_id"], 371 | "channel_id":ret1["data"]["id"], 372 | "bt_channel_id":e.body["target_id"], # 开启该ticket的按钮所在频道的id 373 | "start_time":time.time(), 374 | "lock": False 375 | } 376 | # 键值对映射 377 | TKlog["msg_pair"][sent["msg_id"]] = no # 键值对,msgid映射ticket编号 378 | TKlog["user_pair"][e.body["user_id"]] = no # 用户键值对,一个用户只能创建一个ticket 379 | TKlog["TKchannel"][ret1["data"]["id"]] = no # 记录bot创建的频道id,用于消息日志 380 | 381 | # 6.保存到文件 382 | write_file(TKlogPath, TKlog) 383 | _log.info( 384 | f"[TK.OPEN] Au:{e.body['user_id']} - TkID:{no} at {TKlog['data'][no]['start_time']}" 385 | ) 386 | except: 387 | _log.exception(f"ERR in TK.OPEN | E:{e.body}") 388 | err_str = f"ERR! [{get_time()}] TK.OPEN\n```\n{traceback.format_exc()}\n```" 389 | await debug_ch.send(err_str) 390 | # 如果出现了错误,就把用户键值对给删了,允许创建第二个 391 | if e.body["user_id"] in TKlog["user_pair"]: 392 | _log.info( 393 | f"Au:{e.body['user_id']} del {TKlog['user_pair'][e.body['user_id']]}" 394 | ) 395 | del TKlog["user_pair"][e.body["user_id"]] 396 | 397 | 398 | async def ticket_close_event(b: Bot, e: Event): 399 | """监看工单关闭情况""" 400 | try: 401 | # 避免与tiket申请按钮冲突(文字频道id) 402 | if e.body["target_id"] in TKconf["ticket"]["channel_id"]: 403 | _log.info(f"[TK.CLOSE] BTN.CLICK channel_id in TKconf:{e.body['msg_id']}") 404 | return 405 | 406 | # 判断关闭按钮的卡片消息id是否在以开启的tk日志中,如果不在,则return 407 | if e.body["msg_id"] not in TKlog["msg_pair"]: 408 | _log.info(f"[TK.CLOSE] BTN.CLICK msg_id not in TKlog:{e.body['msg_id']}") 409 | return 410 | 411 | # 基本有效则打印event的json内容 412 | loggingE(e, "TK.CLOSE") 413 | 414 | # 判断是否为管理员,只有管理可以关闭tk 415 | no = TKlog["msg_pair"][e.body["msg_id"]] # 通过消息id获取到ticket的编号 416 | btn_ch_id = TKlog["data"][no]["bt_channel_id"] # 开启该ticket的按钮所在频道的id 417 | if not ( 418 | await user_in_admin_role(e.body["guild_id"], e.body["user_id"], btn_ch_id) 419 | ): 420 | temp_ch = await bot.client.fetch_public_channel(e.body["target_id"]) 421 | await temp_ch.send( 422 | f"```\n抱歉,只有管理员用户可以关闭ticket\n```", temp_target_id=e.body["user_id"] 423 | ) 424 | _log.info( 425 | f"[TK.CLOSE] BTN.CLICK by none admin usr:{e.body['user_id']} | C:{e.body['target_id']}" 426 | ) 427 | return 428 | 429 | # 保存ticket的聊天记录(不在TKMsgLog里面代表一句话都没发) 430 | if e.body["target_id"] in TKMsgLog["TKMsgChannel"]: 431 | filename = f"./log/ticket/{TKlog['msg_pair'][e.body['msg_id']]}.json" 432 | # 保存日志到文件 433 | write_file(filename, TKMsgLog["data"][e.body["target_id"]]) 434 | del TKMsgLog["data"][e.body["target_id"]] # 删除日志文件中的该频道 435 | del TKMsgLog["TKMsgChannel"][e.body["target_id"]] 436 | _log.info( 437 | f"[TK.CLOSE] save log msg of {TKlog['msg_pair'][e.body['msg_id']]}" 438 | ) 439 | 440 | # 保存完毕记录后,删除频道 441 | ret = await delete_channel(e.body["target_id"]) 442 | _log.info(f"[TK.CLOSE] delete channel {e.body['target_id']} | {ret}") 443 | 444 | # 记录信息 445 | TKlog["data"][no]["end_time"] = time.time() # 结束时间 446 | TKlog["data"][no]["end_usr"] = e.body["user_id"] # 是谁关闭的 447 | TKlog["data"][no][ 448 | "end_usr_info" 449 | ] = f"{e.body['user_info']['username']}#{e.body['user_info']['identify_num']}" # 用户名字 450 | del TKlog["msg_pair"][e.body["msg_id"]] # 删除键值对 451 | del TKlog["user_pair"][TKlog["data"][no]['usr_id']] # 删除用户键值对 452 | _log.info(f"[TK.CLOSE] TKlog handling finished | NO:{no}") 453 | 454 | # 发送消息给开启该tk的用户和log频道 455 | cm = CardMessage() 456 | c = Card(Module.Header(f"工单 ticket.{no} 已关闭"), Module.Divider()) 457 | text = f"开启时间: {get_time_str_from_stamp(TKlog['data'][no]['start_time'])}\n" # 时间戳转str 458 | text += f"发起用户: (met){TKlog['data'][no]['usr_id']}(met)\n" 459 | text += f"结束时间: {get_time()}\n" # 当前时间 460 | text += f"关闭用户: (met){TKlog['data'][no]['end_usr']}(met)\n" 461 | c.append(Module.Section(Element.Text(text, Types.Text.KMD))) 462 | cm.append(c) 463 | # 预先定义,避免出现私信错误 464 | log_ch_sent = {"msg_id": "none"} 465 | log_usr_sent = {"msg_id": "none"} 466 | try: 467 | open_usr = await bot.client.fetch_user(TKlog["data"][no]["usr_id"]) 468 | log_usr_sent = await open_usr.send(cm) # 发送给用户 469 | except Exception as result: 470 | if "无法" in str(result) or '屏蔽' in str(result) or 'connect' in str(result): 471 | _log.warning(f"ERR! [TK.CLOSE] Au:{TKlog['data'][no]['usr_id']} | {result}") 472 | else: 473 | raise result 474 | 475 | log_ch_sent = await log_ch.send(cm) # 发送到频道 476 | TKlog["data"][no]["log_ch_msg_id"] = log_ch_sent["msg_id"] 477 | TKlog["data"][no]["log_usr_msg_id"] = log_usr_sent["msg_id"] 478 | _log.info( 479 | f"[TK.CLOSE] TKlog msg send finished - ChMsgID:{log_ch_sent['msg_id']} - UMsgID:{log_usr_sent['msg_id']}" 480 | ) 481 | 482 | # 保存到文件 483 | write_file(TKlogPath, TKlog) 484 | _log.info( 485 | f"[TK.CLOSE] Au:{e.body['user_id']} - TkID:{no} at {TKlog['data'][no]['end_time']}" 486 | ) 487 | except: 488 | _log.exception(f"ERR in [TK.CLOSE] | E:{e.body}") 489 | err_str = f"ERR! [{get_time()}] [TK.CLOSE]\n```\n{traceback.format_exc()}\n```" 490 | await debug_ch.send(err_str) 491 | 492 | @bot.on_message() 493 | async def ticket_msg_log(msg: Message): 494 | """记录ticket频道的聊天记录""" 495 | try: 496 | # 判断频道id是否在以开启的tk日志中,如果不在,则return 497 | if msg.ctx.channel.id not in TKlog["TKchannel"]: 498 | return 499 | # TKlog的初始化时间晚于机器人发送关闭按钮的时间,所以机器人发送的第一条消息是不计入的 500 | # 如果不在TKMsgLog日志中,说明是初次发送消息,则创建键值 501 | no = TKlog["TKchannel"][msg.ctx.channel.id] 502 | if msg.ctx.channel.id not in TKMsgLog["TKMsgChannel"]: 503 | log = {"first_msg_time":time.time(), "msg": {},"msg_num":0} 504 | TKMsgLog["data"][msg.ctx.channel.id] = log 505 | TKMsgLog["TKMsgChannel"][msg.ctx.channel.id] = time.time() # 添加频道,代表该频道有发送过消息 506 | 507 | # 如果在,那么直接添加消息就行 508 | no = TKMsgLog["data"][msg.ctx.channel.id]["msg_num"] # 编号 509 | TKMsgLog["data"][msg.ctx.channel.id]["msg"][str(no)] = { 510 | "msg_id": msg.id, 511 | "channel_id": msg.ctx.channel.id, 512 | "user_id": msg.author_id, 513 | "user_name": f"{msg.author.nickname}#{msg.author.identify_num}", 514 | "content": msg.content, 515 | "time_stamp":time.time() 516 | } 517 | TKMsgLog["data"][msg.ctx.channel.id]["msg_num"] += 1 # 编号+1 518 | # 打印日志 519 | _log.info( 520 | f"TNO:{no} | Au:{msg.author_id} {msg.author.nickname}#{msg.author.identify_num} = {msg.content}" 521 | ) 522 | except: 523 | _log.exception(f"ERR occur | Au:{msg.author_id}") 524 | err_str = f"ERR! [{get_time()}] log_tk_msg\n```\n{traceback.format_exc()}\n```" 525 | await debug_ch.send(err_str) 526 | 527 | async def get_ticket_lock_card(channel_id:str,tk_user_id:str,btn_user_id:str,header_text=""): 528 | """获取工单锁定的卡片 529 | - channel_id: 目标频道 530 | - tk_user_id:工单用户 531 | - btn_user:操作用户 532 | - header_text: 标题文字 533 | """ 534 | text = f"进入锁定状态,禁止用户发言\n操作时间:{get_time()}\n" 535 | text+= f"工单用户:(met){tk_user_id}(met)\n" 536 | text+= f"操作用户:(met){btn_user_id}(met)" 537 | values = json.dumps({"type": TicketBtn.REOPEN, 538 | "channel_id": channel_id,"user_id": tk_user_id}) 539 | cm = CardMessage(Card(Module.Header(header_text), 540 | Module.Section(Element.Text(text,Types.Text.KMD), 541 | Element.Button(text="重新激活",value=values)))) 542 | return cm 543 | 544 | @bot.task.add_interval(minutes=10) 545 | async def ticket_channel_activate_check(): 546 | """检查日志频道是否活跃。 547 | 超过指定天数没有发送信息的频道,将被机器人关闭 548 | """ 549 | global TKMsgLog,TKlog 550 | msg_id = "none" 551 | try: 552 | _log.info(f"[BOT.TASK] activate check start") 553 | # 机器人用户id 554 | bot_id = (await bot.client.fetch_me()).id 555 | # 在tklog msg_pair里面的是所有开启ticket的记录 556 | TKLogTemp = copy.deepcopy(TKlog) 557 | for msg_id,tkno in TKLogTemp["msg_pair"].items(): 558 | # 如果记录里面有endtime代表工单已被关闭,跳过(保证不出错) 559 | if 'end_time' in TKlog['data'][tkno]: 560 | # 报警是因为工单如果被关闭了,应该不会出现在这个循环中 561 | _log.warning(f"[channel.activate] end_time in {tkno}") 562 | continue 563 | # 已经被锁定了,也跳过 564 | if 'lock' not in TKlog["data"][tkno]: 565 | TKlog["data"][tkno] = False # 创建键值为false 566 | if TKlog["data"][tkno]['lock']: 567 | continue 568 | # 获取频道id 569 | ch_id = TKlog["data"][tkno]['channel_id'] 570 | user_id = TKlog["data"][tkno]['usr_id'] # 开启工单的用户id 571 | # 获取工单开始时间的时间戳 572 | ticket_start_time = TKlog['data'][tkno]['start_time'] 573 | assert(isinstance(ticket_start_time,type(time.time()))) # 不能是str 574 | cur_time = get_time_stamp() # 获取当前时间戳 575 | 576 | # 先构造卡片消息 577 | cm = await get_ticket_lock_card(ch_id,user_id,bot_id,f"工单超出「{OUTDATE_HOURS}」小时未活动") 578 | # 超时秒数 = 超时h * 每小时秒数 579 | outdate_sec = OUTDATE_HOURS * 3600 580 | ch = await bot.client.fetch_public_channel(ch_id) 581 | # 1.如果频道id不在msglog里面,代表一次发言都没有过(机器人发言未计入) 582 | if ch_id not in TKMsgLog["TKMsgChannel"]: 583 | time_diff = cur_time-ticket_start_time # 时间插值 584 | if time_diff >= (outdate_sec): 585 | # 超出了超时时间还不发送消息,关闭用户发言权限 586 | await crole_update(ch_id, "user_id", user_id, 2048,4096) 587 | await ch.send(cm) 588 | TKlog["data"][tkno]['lock'] = True 589 | _log.info(f"C:{ch_id} Au:{user_id} | empty channel, lock") 590 | # 两种情况都继续到下一个ticket 591 | continue 592 | 593 | # 2.走到这里代表有消息,筛选出消息时长最大的那个 594 | max_time = 0 595 | for msg_no,msg_info in TKMsgLog["data"][ch_id]['msg'].items(): 596 | time_str = msg_info['time_stamp'] # 消息发送时间 597 | max_time = int(time_str) if int(time_str) > max_time else max_time 598 | # 获取到了list中的最大时间,max_time不能为0 599 | time_diff = cur_time - max_time 600 | if max_time == 0 or time_diff >= (outdate_sec):# 超时时间*每小时秒数 601 | # 超出了超时时间还不发送消息,关闭用户发言权限 602 | await crole_update(ch_id, "user_id", user_id, 2048,4096) 603 | await ch.send(cm) 604 | TKlog["data"][tkno]['lock'] = True 605 | _log.info(f"C:{ch_id} Au:{user_id} | no msg in {OUTDATE_HOURS}h, lock") 606 | # 继续执行下一个ticket 607 | continue 608 | _log.info(f"[BOT.TASK] activate check end") 609 | except: 610 | _log.exception(f"err in task | msg:{msg_id}") 611 | 612 | 613 | async def ticket_reopen_event(b:Bot,e:Event): 614 | """重新激活工单""" 615 | try: 616 | value = json.loads(e.body['value']) # 导入value 617 | user_id = value['user_id'] # 开启该工单的用户 618 | ch_id = value['channel_id'] # 该工单频道 619 | # 判断开启用户是否在键值对中,不在代表有问题 620 | if user_id not in TKlog["user_pair"]: 621 | return _log.warning(f"[TK.REOPEN] Au:{user_id} | C:{ch_id} | user not in pair") 622 | # 获取工单id 623 | no = TKlog["user_pair"][user_id] # 用户键值对:id 624 | # 重新允许用户发言 625 | await crole_update(ch_id, "user_id", user_id, 4096) 626 | # 发送信息到该频道 627 | ch = await bot.client.fetch_public_channel(ch_id) 628 | c = Card(Module.Header(f"工单「{no}」重新激活"),Module.Divider()) 629 | text = f"重启时间:{get_time()}\n" 630 | text+= f"重启用户:(met){user_id}(met)\n" 631 | text+= f"用户ID: {user_id}\n" 632 | c.append(Module.Section(Element.Text(text,Types.Text.KMD))) 633 | await ch.send(CardMessage(c)) 634 | # 修改文件 635 | TKlog["data"][no]['lock'] = False 636 | _log.info(f"[TK.REOPEN] Au:{user_id} | C:{ch_id} | success") 637 | except: 638 | _log.exception(f"ERR in [TK.REOPEN] | E:{e.body}") 639 | 640 | async def ticket_lock_evnet(b:Bot,e:Event): 641 | """锁定工单(效果和机器人自己扫的效果相同)""" 642 | try: 643 | value = json.loads(e.body['value']) # 导入value 644 | user_id = value['user_id'] # 开启该工单的用户 645 | ch_id = e.body["target_id"] # 该工单频道 646 | # 判断开启用户是否在键值对中,不在代表有问题 647 | if user_id not in TKlog["user_pair"]: 648 | return _log.warning(f"[TK.LOCK] Au:{user_id} | C:{ch_id} | user not in pair") 649 | # 获取频道obj 650 | ch = await bot.client.fetch_public_channel(ch_id) 651 | # 获取工单id 652 | no = TKlog["user_pair"][user_id] # 用户键值对:id 653 | if TKlog["data"][no]['lock']:# 已经被锁定了 654 | _log.info(f"[TK.LOCK] Au:{user_id} | C:{ch_id} | already lock") 655 | return await ch.send(f"(met){user_id}(met) 该工单已锁定,请勿二次操作") 656 | # 设置用户权限并发送信息 657 | await crole_update(ch_id, "user_id", user_id, 2048,4096) 658 | cm = await get_ticket_lock_card(ch_id,user_id,e.body['user_id'],f"工单「{no}」手动锁定") 659 | await ch.send(cm) 660 | TKlog["data"][no]['lock'] = True 661 | _log.info(f"[TK.LOCK] Au:{user_id} | C:{ch_id}") 662 | except: 663 | _log.exception(f"ERR in [TK.LOCK] | E:{e.body}") 664 | 665 | @bot.on_event(EventTypes.MESSAGE_BTN_CLICK) 666 | async def btn_click_event_watch(b:Bot,e:Event): 667 | """通过按钮的value,分选给各个函数""" 668 | try: 669 | value = json.loads(e.body['value']) # 导入value 670 | btn_type = value['type'] # 按钮类型 671 | 672 | if btn_type == TicketBtn.OPEN: 673 | global TicketOpenLock # 同一时间只允许创建一个tk 674 | async with TicketOpenLock: 675 | _log.info(f"[TK.OPEN] Au:{e.body['user_id']} C:{e.body['target_id']}") 676 | await ticket_open_event(b,e) 677 | elif btn_type == TicketBtn.CLOSE: 678 | global TicketCloseLock # 同一时间只允许一个tk关闭 679 | async with TicketCloseLock: 680 | _log.info(f"[TK.CLOSE] Au:{e.body['user_id']} C:{e.body['target_id']}") 681 | await ticket_close_event(b,e) 682 | elif btn_type == TicketBtn.REOPEN: 683 | _log.info(f"[TK.REOPEN] Au:{e.body['user_id']} C:{e.body['target_id']}") 684 | await ticket_reopen_event(b,e) 685 | elif btn_type == TicketBtn.LOCK: 686 | _log.info(f"[TK.LOCK] Au:{e.body['user_id']} C:{e.body['target_id']}") 687 | await ticket_lock_evnet(b,e) 688 | else: 689 | _log.warning(f"invalied value.type | {e.body}") 690 | except: 691 | _log.exception(f"err in event watch | {e.body}") 692 | 693 | 694 | ################################以下是给用户上色功能的内容######################################## 695 | 696 | # 只有emoji的键值在配置文件中存在,才启用监看 697 | # 否则不加载这个event,节省性能 698 | if EMOJI_ROLES_ON: 699 | from utils.cmd.grantRoles import grant_role_event 700 | _log.info(f"[BOT.ON_EVENT] loading ADDED_REACTION") 701 | # 添加event监看 702 | @bot.on_event(EventTypes.ADDED_REACTION) 703 | async def grant_role(b: Bot, event: Event): 704 | await grant_role_event(b, event) 705 | # 如果想获取emoji的样式,比如频道自定义emoji,就需要在这里print 706 | # print(event.body) 707 | 708 | ########################################################################################## 709 | 710 | 711 | # 定时保存log file 712 | @bot.task.add_interval(minutes=5) 713 | async def log_file_save_task(): 714 | try: 715 | await write_all_files() 716 | _log.info(f"[FILE.SAVE.TASK] file saved") 717 | logFlush() # 刷新缓冲区 718 | except: 719 | _log.exception(f"[FILE.SAVE] err") 720 | 721 | 722 | # kill命令安全退出 723 | @bot.command(name="kill", case_sensitive=False) 724 | async def kill(msg: Message, atbot="", *arg): 725 | try: 726 | logging(msg) 727 | if not (await user_in_admin_role(msg.ctx.guild.id, msg.author_id)): 728 | return 729 | cur_bot = await bot.client.fetch_me() 730 | if f"(met){cur_bot.id}(met)" not in atbot: 731 | return await msg.reply(f"为了保证命令唯一性,执行本命令必须at机器人!`/kill @机器人`") 732 | 733 | await write_all_files() 734 | # 发送信息提示 735 | await msg.reply(f"[KILL] bot exit") 736 | # 如果是webscoket才调用下线接口 737 | res = "webhook" 738 | if Botconf["ws"]: 739 | res = await bot_offline() # 调用接口下线bot 740 | _log.info( 741 | f"[KILL] [{get_time()}] Au:{msg.author_id} | bot-off: {res}\n" 742 | ) # 打印下线日志 743 | logFlush() # 刷新缓冲区 744 | os._exit(0) # 进程退出 745 | except: 746 | _log.exception(f"kill err") 747 | await msg.reply(f"kill err\n```\n{traceback.format_exc()}\n```") 748 | 749 | # 开机任务 750 | @bot.on_startup 751 | async def loading_channel(b:Bot): 752 | try: 753 | global debug_ch, log_ch 754 | debug_ch = await bot.client.fetch_public_channel( 755 | TKconf["ticket"]["debug_channel"] 756 | ) 757 | log_ch = await bot.client.fetch_public_channel(TKconf["ticket"]["log_channel"]) 758 | _log.info(f"[BOT.START] fetch_public_channel success") 759 | botStatus.init(bot) # 机器人动态相关命令 760 | logFlush() # 刷新缓冲区 761 | except: 762 | _log.exception(f"[BOT.START] fetch_public_channel failed") 763 | _log.critical("[BOT.START] 获取频道失败,请检查TicketConf文件中的debug_channel和log_channel\n") 764 | logFlush() # 刷新缓冲区 765 | os.abort() # 出现错误直接退出程序 766 | 767 | 768 | # 如果是主文件就开机 769 | if __name__ == "__main__": 770 | # 开机的时候打印一次时间,记录开启时间 771 | _log.info(f"[BOT] Start at {start_time}") 772 | # 开机 773 | bot.run() 774 | -------------------------------------------------------------------------------- /main.sh: -------------------------------------------------------------------------------- 1 | git clone https://github.com/musnows/Kook-Ticket-Bot.git 2 | cd Kook-Ticket-Bot 3 | # 请在成功部署后注释掉前两行👆 4 | 5 | # 复制所有文件到根目录,然后删除git文件夹 6 | mv * ../ 7 | rm -rf Kook-Ticket-Bot -------------------------------------------------------------------------------- /replit.nix: -------------------------------------------------------------------------------- 1 | { pkgs }: { 2 | deps = [ 3 | pkgs.systemd 4 | pkgs.python310Full 5 | pkgs.replitPackages.prybar-python310 6 | pkgs.replitPackages.stderred 7 | ]; 8 | env = { 9 | PYTHON_LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ 10 | # Needed for pandas / numpy 11 | pkgs.stdenv.cc.cc.lib 12 | pkgs.zlib 13 | # Needed for pygame 14 | pkgs.glib 15 | # Needed for matplotlib 16 | pkgs.xorg.libX11 17 | ]; 18 | PYTHONHOME = "${pkgs.python310Full}"; 19 | PYTHONBIN = "${pkgs.python310Full}/bin/python3.10"; 20 | LANG = "en_US.UTF-8"; 21 | STDERREDBIN = "${pkgs.replitPackages.stderred}/bin/stderred"; 22 | PRYBAR_PYTHON_BIN = "${pkgs.replitPackages.prybar-python310}/bin/prybar-python310"; 23 | }; 24 | } -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp==3.8.1 2 | khl.py==0.3.7 3 | requests==2.28.1 4 | -------------------------------------------------------------------------------- /screenshots/emoji_id.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/musnows/Kook-Ticket-Bot/eab012496fbc9e727e0827c886b5d3d937e38de8/screenshots/emoji_id.png -------------------------------------------------------------------------------- /screenshots/emoji_role_rules.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/musnows/Kook-Ticket-Bot/eab012496fbc9e727e0827c886b5d3d937e38de8/screenshots/emoji_role_rules.png -------------------------------------------------------------------------------- /screenshots/help_cmd.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/musnows/Kook-Ticket-Bot/eab012496fbc9e727e0827c886b5d3d937e38de8/screenshots/help_cmd.png -------------------------------------------------------------------------------- /screenshots/hide_chc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/musnows/Kook-Ticket-Bot/eab012496fbc9e727e0827c886b5d3d937e38de8/screenshots/hide_chc.png -------------------------------------------------------------------------------- /screenshots/kook_develop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/musnows/Kook-Ticket-Bot/eab012496fbc9e727e0827c886b5d3d937e38de8/screenshots/kook_develop.png -------------------------------------------------------------------------------- /screenshots/role1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/musnows/Kook-Ticket-Bot/eab012496fbc9e727e0827c886b5d3d937e38de8/screenshots/role1.png -------------------------------------------------------------------------------- /screenshots/role2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/musnows/Kook-Ticket-Bot/eab012496fbc9e727e0827c886b5d3d937e38de8/screenshots/role2.png -------------------------------------------------------------------------------- /screenshots/role3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/musnows/Kook-Ticket-Bot/eab012496fbc9e727e0827c886b5d3d937e38de8/screenshots/role3.png -------------------------------------------------------------------------------- /screenshots/role_id.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/musnows/Kook-Ticket-Bot/eab012496fbc9e727e0827c886b5d3d937e38de8/screenshots/role_id.png -------------------------------------------------------------------------------- /screenshots/tk1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/musnows/Kook-Ticket-Bot/eab012496fbc9e727e0827c886b5d3d937e38de8/screenshots/tk1.png -------------------------------------------------------------------------------- /screenshots/tk2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/musnows/Kook-Ticket-Bot/eab012496fbc9e727e0827c886b5d3d937e38de8/screenshots/tk2.png -------------------------------------------------------------------------------- /screenshots/tk3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/musnows/Kook-Ticket-Bot/eab012496fbc9e727e0827c886b5d3d937e38de8/screenshots/tk3.png -------------------------------------------------------------------------------- /screenshots/tk4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/musnows/Kook-Ticket-Bot/eab012496fbc9e727e0827c886b5d3d937e38de8/screenshots/tk4.png -------------------------------------------------------------------------------- /utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/musnows/Kook-Ticket-Bot/eab012496fbc9e727e0827c886b5d3d937e38de8/utils/__init__.py -------------------------------------------------------------------------------- /utils/cmd/botStatus.py: -------------------------------------------------------------------------------- 1 | # 机器人在玩状态相关功能 2 | import traceback 3 | from khl import Bot,Message 4 | from ..file import logging,_log 5 | from ..kookApi import status_active_game,status_active_music,status_delete 6 | from ..gtime import get_time 7 | 8 | def init(bot:Bot): 9 | # 开始打游戏 10 | @bot.command(name="game", aliases=["gaming"], case_sensitive=False) 11 | async def gaming(msg: Message, game: int = 0, *arg): 12 | logging(msg) 13 | try: 14 | if game == 0: 15 | await msg.reply(f"[gaming] 参数错误,用法「/gaming 数字」\n1-人间地狱,2-英雄联盟,3-CSGO") 16 | return 17 | elif game == 1: 18 | ret = await status_active_game(464053) # 人间地狱 19 | await msg.reply(f"{ret['message']},Bot上号人间地狱啦!") 20 | elif game == 2: 21 | ret = await status_active_game(3) # 英雄联盟 22 | await msg.reply(f"{ret['message']},Bot上号LOL啦!") 23 | elif game == 3: 24 | ret = await status_active_game(23) # CSGO 25 | await msg.reply(f"{ret['message']},Bot上号CSGO啦!") 26 | 27 | except Exception as result: 28 | _log.exception(f"Au:{msg.author_id} | ERR") 29 | await msg.reply(f"ERR! [{get_time()}] game\n```\n{traceback.format_exc()}\n```") 30 | 31 | 32 | # 开始听歌 33 | @bot.command(name="sing", aliases=["singing"], case_sensitive=False) 34 | async def singing(msg: Message, music: str = "e", singer: str = "e", *arg): 35 | logging(msg) 36 | try: 37 | if music == "e" or singer == "e": 38 | await msg.reply(f"[singing] 参数错误,用法「/singing 歌名 歌手」") 39 | return 40 | # 参数正确,开始操作 41 | ret = await status_active_music(music, singer) 42 | await msg.reply(f"{ret['message']},Bot开始听歌啦!") 43 | except Exception as result: 44 | _log.exception(f"Au:{msg.author_id} | ERR") 45 | await msg.reply(f"ERR! [{get_time()}] sing\n```\n{traceback.format_exc()}\n```") 46 | 47 | 48 | # 停止打游戏1/听歌2 49 | @bot.command(name="sleep", case_sensitive=False) 50 | async def sleeping(msg: Message, d: int = 0, *arg): 51 | logging(msg) 52 | try: 53 | if d == 0: 54 | await msg.reply(f"[sleep] 参数错误,用法「/sleep 数字」\n1-停止游戏,2-停止听歌") 55 | ret = await status_delete(d) 56 | if d == 1: 57 | await msg.reply(f"{ret['message']},Bot下号休息啦!") 58 | elif d == 2: 59 | await msg.reply(f"{ret['message']},Bot摘下了耳机~") 60 | except Exception as result: 61 | _log.exception(f"Au:{msg.author_id} | ERR") 62 | await msg.reply(f"ERR! [{get_time()}] sleep\n```\n{traceback.format_exc()}\n```") 63 | 64 | _log.info(f"load botStatus.py") -------------------------------------------------------------------------------- /utils/cmd/grantRoles.py: -------------------------------------------------------------------------------- 1 | import traceback 2 | from khl import Event,Bot,Channel 3 | 4 | from ..file import ColorIdDict,ColorIdPath,write_file,_log,TKconf 5 | 6 | async def save_userid_color(userid: str, emoji: str, uid: str): 7 | """用于记录使用表情回应获取ID颜色的用户 8 | 9 | Args: 10 | - userid: kook-user-id 11 | - emoji: emoji id 12 | - uid: str in TKconf['emoji'] 13 | 14 | Return: 15 | - True: old user 16 | - False: new user 17 | """ 18 | global ColorIdDict 19 | flag = True 20 | # 如果键值不在,创建键值,代表之前没有获取过角色 21 | if uid not in ColorIdDict["data"]: 22 | ColorIdDict["data"][uid] = {} 23 | flag = False 24 | # 更新键值 25 | ColorIdDict["data"][uid][userid] = emoji 26 | # 如果用户是第一次添加表情回应,那就写入文件 27 | if flag: 28 | write_file(ColorIdPath, ColorIdDict) 29 | return flag 30 | 31 | 32 | # 给用户上角色 33 | async def grant_role_event(bot: Bot, event: Event,debug_ch:Channel,guild_id:str): 34 | """判断消息的emoji回应,并给予不同角色""" 35 | ch = debug_ch 36 | try: 37 | # 将event.body的msg_id和配置文件中msg_id进行对比,确认是那一条消息的表情回应 38 | for euid, econf in TKconf["emoji"].items(): 39 | if event.body["msg_id"] != econf["msg_id"]: 40 | continue 41 | # 1.这里的打印eventbody的完整内容,包含emoji_id 42 | _log.info(f"React:{event.body}") 43 | # 2.获取对象 44 | g = await bot.client.fetch_guild(guild_id) # 获取服务器(msg_id合法才获取,避免多次无效调用api) 45 | ch = await bot.client.fetch_public_channel( 46 | event.body["channel_id"] 47 | ) # 获取事件频道 48 | user = await g.fetch_user(event.body["user_id"]) # 通过event获取用户id(对象) 49 | # 3.判断用户回复的emoji是否合法 50 | emoji = event.body["emoji"]["id"] 51 | if emoji not in econf["data"]: # 不在配置文件中,忽略 52 | return await ch.send( 53 | f"你回应的表情不在列表中哦~再试一次吧!", temp_target_id=event.body["user_id"] 54 | ) 55 | 56 | # 4.判断用户之前是否已经获取过角色 57 | ret = await save_userid_color( 58 | event.body["user_id"], event.body["emoji"]["id"], euid 59 | ) 60 | text = f"「{user.nickname}#{user.identify_num}」Bot已经给你上了 「{emoji}」 对应的角色啦~" 61 | if ret: # 已经获取过角色 62 | text += "\n上次获取的角色已删除" 63 | # 5.给予角色 64 | role = int(econf["data"][emoji]) # 角色id 65 | await g.grant_role(user, role) # 上角色 66 | # 6.发送提示信息给用户 67 | await ch.send(text, temp_target_id=event.body["user_id"]) 68 | _log.info(f"Au:{user.id} | grant rid:{role}") 69 | except Exception as result: 70 | _log.exception(f"ERR | E:{event.body}") 71 | err_text = ( 72 | f"上角色时出现了错误!Au:{event.body['user_id']}\n```\n{traceback.format_exc()}\n```" 73 | ) 74 | if ch != debug_ch: 75 | await ch.send(err_text, temp_target_id=event.body["user_id"]) 76 | else: 77 | await ch.send(err_text) -------------------------------------------------------------------------------- /utils/file.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import sys 4 | import asyncio 5 | from khl import Message, Event,PrivateMessage 6 | from .gtime import get_time 7 | from .myLog import _log 8 | 9 | start_time = get_time() 10 | """记录开机时间""" 11 | 12 | def open_file(path:str): 13 | """打开文件""" 14 | assert(isinstance(path,str)) # 如果路径不是str,报错 15 | with open(path, 'r', encoding='utf-8') as f: 16 | tmp = json.load(f) 17 | return tmp 18 | 19 | 20 | def write_file(path: str, value): 21 | """写入文件,仅支持json格式的dict或者list""" 22 | assert(isinstance(path,str)) # 如果路径不是str,报错 23 | with open(path, 'w+', encoding='utf-8') as fw2: 24 | json.dump(value, fw2, indent=2, sort_keys=True, ensure_ascii=False) 25 | 26 | 27 | # 刷新缓冲区 28 | def logFlush(): 29 | sys.stdout.flush() # 刷新缓冲区 30 | sys.stderr.flush() # 刷新缓冲区 31 | 32 | 33 | # 设置日志文件的重定向 34 | def logDup(path: str = './log/log.txt'): 35 | file = open(path, 'a') 36 | sys.stdout = file 37 | sys.stderr = file 38 | _log.info(f"stdout/stderr dup to {path}") 39 | logFlush() 40 | 41 | 42 | def logging(msg: Message) -> bool: 43 | """打印msg内容,用作日志 44 | - true: 公屏,允许运行 45 | - false:私聊,不给运行""" 46 | if isinstance(msg,PrivateMessage): 47 | _log.info( 48 | f"PmMsg - Au:{msg.author_id} {msg.author.username}#{msg.author.identify_num} - content:{msg.content}" 49 | ) 50 | return False 51 | else: 52 | _log.info( 53 | f"G:{msg.ctx.guild.id} - C:{msg.ctx.channel.id} - Au:{msg.author_id} {msg.author.username}#{msg.author.identify_num} - content:{msg.content}" 54 | ) 55 | return True 56 | 57 | 58 | def loggingE(e: Event, func=" "): 59 | """打印event的日志""" 60 | _log.info(f"{func} | Event:{e.body}") 61 | 62 | 63 | def create_log_file(path: str, content): 64 | """创建根文件/文件夹 65 | 66 | Retrun value 67 | - False: path exist but keyerr / create false 68 | - True: path exist / path not exist, create success 69 | """ 70 | try: 71 | # 如果文件路径存在 72 | if os.path.exists(path): 73 | tmp = open_file(path) # 打开文件 74 | for key in content: # 遍历默认的键值 75 | if key not in tmp: # 判断是否存在 76 | _log.warning( 77 | f"[file] ERR! files exists, but key '{key}' not in {path}" 78 | ) 79 | return False 80 | return True 81 | # 文件路径不存在,通过content写入path 82 | write_file(path, content) 83 | return True 84 | except Exception as result: 85 | _log.exception(f"create logFile ERR") 86 | return False 87 | 88 | 89 | 90 | ############################################################################################### 91 | 92 | # 所有文件如下 93 | BotConfPath = "./config/config.json" 94 | """机器人配置文件路径""" 95 | TKConfPath = "./config/TicketConf.json" 96 | """工单配置文件路径""" 97 | 98 | Botconf = open_file(BotConfPath) 99 | """机器人配置文件""" 100 | TKconf = open_file(TKConfPath) 101 | """工单配置文件/表情角色配置文件""" 102 | __ColorIdDictExp = {"data":{}} 103 | """记录用户在某个消息下获取的角色""" 104 | __TKlogExp = { 105 | "TKnum": 0, 106 | "data": {}, 107 | "msg_pair": {}, 108 | "TKchannel": {}, 109 | "user_pair":{} 110 | } 111 | """ticket编号和历史记录""" 112 | __TKMsgLogExp = {"TKMsgChannel": {}, "data": {}} 113 | """ticket 消息记录""" 114 | 115 | 116 | # 日志文件路径 117 | LogPath = './log' 118 | """根路径""" 119 | TKlogPath = './log/TicketLog.json' 120 | """工单日志 TicketLog.json""" 121 | TKMsgLogPath = './log/TicketMsgLog.json' 122 | """工单消息日志 TicketMsgLog.json""" 123 | TKLogFilePath = './log/ticket' 124 | """存放ticket消息记录日志的文件夹""" 125 | ColorIdPath = './log/ColorID.json' 126 | """表情上角色日志 ColorID.json""" 127 | EMOJI_ROLES_ON:bool = 'emoji' in TKconf and TKconf['emoji'] != {} 128 | """是否开启了表情回应上角色的功能""" 129 | 130 | try: 131 | # 如果log路径不存在,创建log文件夹 132 | if (not os.path.exists(LogPath)): 133 | os.makedirs(LogPath) # 文件夹不存在,创建 134 | # 自动创建TicketLog和TicketMsgLog日志文件 135 | if (not create_log_file(TKlogPath,__TKlogExp)): 136 | os._exit(-1) # err,退出进程 137 | if (not create_log_file(TKMsgLogPath, __TKMsgLogExp)): 138 | os._exit(-1) # err,退出进程 139 | # 创建 ./log/ticket 文件夹,用于存放ticket的日志记录 140 | if (not os.path.exists(TKLogFilePath)): 141 | os.makedirs(TKLogFilePath) # 文件夹不存在,创建 142 | 143 | # 创建日志文件成功,打开 144 | TKlog = open_file(TKlogPath) 145 | TKMsgLog = open_file(TKMsgLogPath) 146 | # 配置文件中,EMOJI键值存在才会加载 147 | if EMOJI_ROLES_ON: 148 | # 自动创建ColorID日志文件 149 | if (not create_log_file(ColorIdPath, __ColorIdDictExp)): 150 | os._exit(-1) # err,退出进程 151 | # 没有错误,打开文件 152 | ColorIdDict = open_file(ColorIdPath) # 记录用户在某个消息下获取的角色 153 | 154 | _log.info(f"[BOT.START] open log.files success!") 155 | except: 156 | _log.info(f"[BOT.START] open log.files ERR") 157 | os._exit(-1) 158 | 159 | FileSaveLock = asyncio.Lock() 160 | """保存文件上锁""" 161 | async def write_all_files(): 162 | """写入所有文件""" 163 | global FileSaveLock 164 | async with FileSaveLock: 165 | write_file(TKMsgLogPath, TKMsgLog) 166 | write_file(TKlogPath,TKlog) 167 | if EMOJI_ROLES_ON: 168 | write_file(ColorIdPath, ColorIdDict) 169 | _log.info(f"[write.file] file saved at {get_time()}") -------------------------------------------------------------------------------- /utils/gtime.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime,timezone,timedelta 2 | 3 | def get_time(): 4 | """获取当前时间,格式为 `23-01-01 00:00:00`""" 5 | utc_dt = datetime.now(timezone.utc) # 获取当前时间 6 | bj_dt = utc_dt.astimezone(timezone(timedelta(hours=8))) # 转换为北京时间 7 | return bj_dt.strftime('%y-%m-%d %H:%M:%S') 8 | # use time.loacltime if you aren't using BeiJing Time 9 | # return time.strftime("%y-%m-%d %H:%M:%S", time.localtime()) 10 | 11 | def get_time_stamp(): 12 | """获取当前时间戳(北京时间)""" 13 | utc_dt = datetime.now(timezone.utc) # 获取当前时间 14 | bj_dt = utc_dt.astimezone(timezone(timedelta(hours=8))) # 转换为北京时间 15 | return bj_dt.timestamp() 16 | 17 | def get_time_stamp_from_str(time_str:str): 18 | """从可读时间转为时间戳,格式 23-01-01 00:00:00 19 | - 如果传入的只有日期,如23-01-01,则会自动获取当日0点的时间戳 20 | """ 21 | if len(time_str) == 8: 22 | time_str+=" 00:00:00" 23 | dt = datetime.strptime(time_str, '%y-%m-%d %H:%M:%S') 24 | tz = timezone(timedelta(hours=8)) 25 | dt = dt.astimezone(tz) 26 | return dt.timestamp() 27 | 28 | 29 | def get_time_str_from_stamp(timestamp): 30 | """通过时间戳获取当前的本地时间,格式 23-01-01 00:00:00""" 31 | a = datetime.fromtimestamp(timestamp,tz=timezone(timedelta(hours=8))) 32 | return a.strftime("%y-%m-%d %H:%M:%S") 33 | 34 | 35 | # import time 36 | # print(get_time_stamp()) 37 | # print(time.time()) -------------------------------------------------------------------------------- /utils/help.py: -------------------------------------------------------------------------------- 1 | def help_text(text=""): 2 | """help命令的内容""" 3 | text+=f"`/ticket` 在本频道发送一条消息,作为ticket的开启按钮\n" 4 | text+=f"`/tkcm 工单id 备注` 对某一条已经关闭的工单进行备注\n" 5 | text+=f"`/aar @角色组` 将角色添加进入管理员角色\n" 6 | text+=f"```\nid获取办法:kook设置-高级设置-打开开发者模式;右键用户头像即可复制用户id,右键频道/分组即可复制id,角色id需要进入服务器管理面板的角色页面中右键复制\n```\n" 7 | # text+=f"以上命令都需要管理员才能操作\n" 8 | text+=f"`/gaming 游戏选项` 让机器人开始打游戏(代码中指定了几个游戏)\n" 9 | text+=f"`/singing 歌名 歌手` 让机器人开始听歌\n" 10 | text+=f"`/sleeping 1(2)` 让机器人停止打游戏1 or 听歌2\n" 11 | return text -------------------------------------------------------------------------------- /utils/kookApi.py: -------------------------------------------------------------------------------- 1 | import json 2 | import aiohttp 3 | from typing import Union 4 | from khl import Bot,ChannelPrivacyTypes 5 | from .file import Botconf 6 | from .myLog import _log 7 | 8 | # kook api的头链接,请不要修改 9 | kook_base="https://www.kookapp.cn" 10 | """kook api base url""" 11 | kook_headers={f'Authorization': f"Bot {Botconf['token']}"} 12 | """kook api base headers""" 13 | 14 | 15 | async def status_active_game(game:int): 16 | """让机器人开始打游戏,文档 https://developer.kookapp.cn/doc/http/game 17 | - game:游戏id,必须要用接口创建 18 | """ 19 | url="https://www.kookapp.cn/api/v3/game/activity" 20 | params = {"id": game ,"data_type":1} 21 | async with aiohttp.ClientSession() as session: 22 | async with session.post(url, data=params,headers=kook_headers) as response: 23 | return json.loads(await response.text()) 24 | 25 | async def status_active_music(name:str,singer:str): 26 | """让机器人开始听歌 27 | - name: 歌名 28 | - singer:歌手 29 | """ 30 | url="https://www.kookapp.cn/api/v3/game/activity" 31 | params = {"data_type":2,"software":"qqmusic","singer":singer,"music_name":name} 32 | async with aiohttp.ClientSession() as session: 33 | async with session.post(url, data=params,headers=kook_headers) as response: 34 | return json.loads(await response.text()) 35 | 36 | 37 | async def status_delete(d:int): 38 | """删除机器人的当前动态 39 | - 2 音乐 40 | - 1 游戏 41 | """ 42 | url="https://www.kookapp.cn/api/v3/game/delete-activity" 43 | params = {"data_type":d} 44 | async with aiohttp.ClientSession() as session: 45 | async with session.post(url, data=params,headers=kook_headers) as response: 46 | return json.loads(await response.text()) 47 | #_log.debug(ret) 48 | 49 | async def upd_card(bot:Bot,msg_id: str, 50 | content, 51 | target_id='', 52 | channel_type: Union[ChannelPrivacyTypes, str] = 'public'): 53 | """更新卡片消息 54 | - msg_id: 目标消息id 55 | - content:可以是str也可以是卡片消息,但是必须要和被更新的消息类型一致 56 | - channel_type: 公屏还是私聊""" 57 | content = json.dumps(content) 58 | data = {'msg_id': msg_id, 'content': content} 59 | if target_id != '': 60 | data['temp_target_id'] = target_id 61 | if channel_type == 'public' or channel_type == ChannelPrivacyTypes.GROUP: 62 | result = await bot.client.gate.request('POST', 'message/update', data=data) 63 | else: 64 | result = await bot.client.gate.request('POST', 'direct-message/update', data=data) 65 | return result 66 | 67 | 68 | async def has_admin(bot:Bot,user_id:str, guild_id:str): 69 | """判断用户是否拥有管理员角色权限""" 70 | guild = await bot.client.fetch_guild(guild_id) 71 | user_roles = (await guild.fetch_user(user_id)).roles 72 | guild_roles = await (await bot.client.fetch_guild(guild_id)).fetch_roles() 73 | for i in guild_roles: # 遍历服务器身分组 74 | if i.id in user_roles and i.has_permission(0): # 查看当前遍历到的身分组是否在用户身分组内且是否有管理员权限 75 | return True 76 | if user_id == guild.master_id: # 由于腐竹可能没给自己上身分组,但是依旧拥有管理员权限 77 | return True 78 | return False 79 | 80 | 81 | async def channel_create(guild_id:str,parent_id:str,name:str): 82 | """创建文字频道 83 | - guild_id:服务器id 84 | - parent_id: 父分组id 85 | - name:频道名字""" 86 | url1=kook_base+"/api/v3/channel/create"# 创建频道 87 | params1 = {"guild_id": guild_id ,"parent_id":parent_id,"name":name} 88 | async with aiohttp.ClientSession() as session: 89 | async with session.post(url1, data=params1,headers=kook_headers) as response: 90 | ret1=json.loads(await response.text()) 91 | #_log.debug(ret1["data"]["id"]) 92 | return ret1 93 | 94 | 95 | async def crole_create(channel_id:str,_type:str,_value:str): 96 | """创建角色权限 97 | - type: user_id / role_id 98 | - value: base on type 99 | """ 100 | url2=kook_base+"/api/v3/channel-role/create"#创建角色权限 101 | params2 = {"channel_id": channel_id ,"type":_type,"value":_value} 102 | async with aiohttp.ClientSession() as session: 103 | async with session.post(url2, data=params2,headers=kook_headers) as response: 104 | ret2=json.loads(await response.text()) 105 | #_log.debug(f"ret2: {ret2}") 106 | return ret2 107 | 108 | 109 | async def crole_update(channel_id:str,_type:str,_value:str,_allow:int,_deny:int=-1): 110 | """设置角色权限,服务器角色权限值见 https://developer.kaiheila.cn/doc/http/guild-role 111 | - type: user_id / role_id 112 | - value: base on type 113 | """ 114 | url3=kook_base+"/api/v3/channel-role/update"#设置角色权限 115 | params3 = {"channel_id": channel_id ,"type":_type,"value":_value,"allow":_allow} 116 | if _deny!=-1: params3["deny"] = _deny 117 | async with aiohttp.ClientSession() as session: 118 | async with session.post(url3, data=params3,headers=kook_headers) as response: 119 | ret3=json.loads(await response.text()) 120 | _log.debug(ret3) 121 | return ret3 122 | 123 | async def bot_offline(): 124 | """下线机器人""" 125 | url = kook_base + "/api/v3/user/offline" 126 | async with aiohttp.ClientSession() as session: 127 | async with session.post(url, headers=kook_headers) as response: 128 | res = json.loads(await response.text()) 129 | return res 130 | 131 | 132 | async def direct_msg_delete(msg_id:str): 133 | """删除私聊消息""" 134 | url = kook_base + "/api/v3/direct-message/delete" 135 | params = {"msg_id":msg_id} 136 | async with aiohttp.ClientSession() as session: 137 | async with session.post(url, data=params,headers=kook_headers) as response: 138 | res = json.loads(await response.text()) 139 | return res 140 | 141 | async def delete_channel(channel_id:str): 142 | """删除指定频道""" 143 | url2 = kook_base + '/api/v3/channel/delete' 144 | params2 = {"channel_id": channel_id} 145 | async with aiohttp.ClientSession() as session: 146 | async with session.post(url2, data=params2,headers=kook_headers) as response: 147 | ret2 = json.loads(await response.text()) 148 | return ret2 -------------------------------------------------------------------------------- /utils/myLog.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone, timedelta 2 | import logging # 采用logging来替换所有print 3 | 4 | LOGGER_NAME = "bot-log" 5 | LOGGER_FILE = "./config/log/bot.log" # 如果想修改log文件的名字和路径,修改此变量 6 | 7 | 8 | def beijing(sec, what): 9 | utc_dt = datetime.now(timezone.utc) # 获取当前时间 10 | beijing_time = utc_dt.astimezone(timezone(timedelta(hours=8))) # 转换为北京时间 11 | return beijing_time.timetuple() 12 | 13 | 14 | # 日志时间改为北京时间 15 | logging.Formatter.converter = beijing # type:ignore 16 | 17 | # 只打印info以上的日志(debug低于info) 18 | logging.basicConfig(level=logging.INFO, 19 | format="[%(asctime)s] %(levelname)s:%(filename)s:%(funcName)s:%(lineno)d | %(message)s", 20 | datefmt="%y-%m-%d %H:%M:%S") 21 | # 获取一个logger对象 22 | _log = logging.getLogger(LOGGER_NAME) 23 | """自定义的logger对象""" 24 | # 实例化控制台handler和文件handler,同时输出到控制台和文件 25 | # cmd_handler = logging.StreamHandler() # 默认设置里面,就会往控制台打印信息;自己又加一个,导致打印俩次 26 | file_handler = logging.FileHandler(LOGGER_FILE, mode="a", encoding="utf-8") 27 | fmt = logging.Formatter(fmt="[%(asctime)s] %(levelname)s:%(filename)s:%(funcName)s:%(lineno)d | %(message)s", 28 | datefmt="%y-%m-%d %H:%M:%S") 29 | file_handler.setFormatter(fmt) 30 | _log.addHandler(file_handler) 31 | -------------------------------------------------------------------------------- /version.py: -------------------------------------------------------------------------------- 1 | APP_VERSION = 'v0.3.0' --------------------------------------------------------------------------------