mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-06-03 02:27:09 +08:00
Compare commits
346 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a52f54d988 | ||
|
|
618c94edb8 | ||
|
|
eaf4e9174f | ||
|
|
4af2c7f3d7 | ||
|
|
361f599df0 | ||
|
|
ffe4ea5e4c | ||
|
|
9461e3e01a | ||
|
|
7c85c6f742 | ||
|
|
b5df6faadf | ||
|
|
7cefe2d825 | ||
|
|
350633b69b | ||
|
|
1cd6a71ce0 | ||
|
|
3a08b002a0 | ||
|
|
cca49da730 | ||
|
|
f6d370ad29 | ||
|
|
c9131b333b | ||
|
|
e44161bf42 | ||
|
|
a26189fb25 | ||
|
|
89dd8a1db6 | ||
|
|
650e0b4ad4 | ||
|
|
c60f0517fb | ||
|
|
0f8dc91a8b | ||
|
|
b58feb5d8e | ||
|
|
71c8043699 | ||
|
|
40264bc9cb | ||
|
|
a7772316f9 | ||
|
|
34209021c8 | ||
|
|
1e58c1ad2b | ||
|
|
8cea022ec5 | ||
|
|
f32f8aa08e | ||
|
|
0a7d6e4577 | ||
|
|
df4c1f0401 | ||
|
|
9a86a67984 | ||
|
|
a0cbe9c3e2 | ||
|
|
a83e5a9b65 | ||
|
|
de33911460 | ||
|
|
0be56e5b25 | ||
|
|
abcbb34b1c | ||
|
|
6a13dd04a3 | ||
|
|
f2e29f3f2e | ||
|
|
68361cddd2 | ||
|
|
6404332adc | ||
|
|
e060b6fea2 | ||
|
|
e8aae27ee9 | ||
|
|
2f732e5493 | ||
|
|
65f20ff2c1 | ||
|
|
8f72e8c3e6 | ||
|
|
3b8972ce1f | ||
|
|
fc5d3e4e9c | ||
|
|
29fbf69945 | ||
|
|
583440b82b | ||
|
|
720de9d73f | ||
|
|
78332d882b | ||
|
|
2dfbc840b3 | ||
|
|
0b4bf15163 | ||
|
|
2989249e4b | ||
|
|
9cef559a05 | ||
|
|
47fe16c92a | ||
|
|
36b5c821ff | ||
|
|
82ec440b45 | ||
|
|
88f4a45cae | ||
|
|
7fb4f72b84 | ||
|
|
d4fc322101 | ||
|
|
8fa3da9ca5 | ||
|
|
68ef5aa3ae | ||
|
|
28bd917c9f | ||
|
|
0eb1b94300 | ||
|
|
15e6cf850b | ||
|
|
ee91c86a29 | ||
|
|
48c08f4aad | ||
|
|
fceabb8e67 | ||
|
|
fcfafb05f1 | ||
|
|
f1e8344beb | ||
|
|
f687b2b6f4 | ||
|
|
8ee7a48151 | ||
|
|
89e8f385b4 | ||
|
|
bf4ae9a051 | ||
|
|
6bd1242d43 | ||
|
|
8779eab36b | ||
|
|
3174b1158c | ||
|
|
18740093d1 | ||
|
|
8c7d1d4010 | ||
|
|
8c48a27e1a | ||
|
|
4278d2b8ef | ||
|
|
3a3affd3ec | ||
|
|
45d72b8b9b | ||
|
|
03b908c079 | ||
|
|
d35d01f980 | ||
|
|
9c208ffa2c | ||
|
|
bea4416f12 | ||
|
|
2ea8b4ef73 | ||
|
|
e6946ef989 | ||
|
|
9aeb60f66d | ||
|
|
d687f9329e | ||
|
|
3207258fd9 | ||
|
|
d8b75206fe | ||
|
|
88e8dd5162 | ||
|
|
c9306633b2 | ||
|
|
c50d1cc99d | ||
|
|
9a20c1cb02 | ||
|
|
176f77ba5b | ||
|
|
484de6237b | ||
|
|
898aa30b1d | ||
|
|
8b73a74609 | ||
|
|
3c6d42b22e | ||
|
|
40563c1e96 | ||
|
|
cb0c86ec1c | ||
|
|
614f3b1ea4 | ||
|
|
938e3b5cf2 | ||
|
|
5fe8d9a855 | ||
|
|
8193ecf5f6 | ||
|
|
1dff630257 | ||
|
|
eaac3e3579 | ||
|
|
d3758968d0 | ||
|
|
020f9a8d98 | ||
|
|
9d8ae80548 | ||
|
|
7e7484a27d | ||
|
|
0adf8d6e5d | ||
|
|
1a981ea970 | ||
|
|
5bd9f50818 | ||
|
|
44f6892cb7 | ||
|
|
fdf6b0dc6b | ||
|
|
a7914279a9 | ||
|
|
2cf71dd6f2 | ||
|
|
62e3baba20 | ||
|
|
e00c99c1d7 | ||
|
|
31d5b95611 | ||
|
|
cc881adda6 | ||
|
|
78d4c58b70 | ||
|
|
eca369532d | ||
|
|
9520d94b13 | ||
|
|
f973bc3fe2 | ||
|
|
94004b095b | ||
|
|
f652d592bd | ||
|
|
186e18fe94 | ||
|
|
28eb67bc24 | ||
|
|
6c7e4aaf37 | ||
|
|
709a1317ef | ||
|
|
371e38cfa6 | ||
|
|
5a221848e9 | ||
|
|
6901c5ba56 | ||
|
|
21a3b0d9a1 | ||
|
|
29422edcc9 | ||
|
|
2da1c18b71 | ||
|
|
be592cc290 | ||
|
|
ce8635dd99 | ||
|
|
76783f0ad3 | ||
|
|
441228e200 | ||
|
|
45a131aa0d | ||
|
|
a7900d4b2c | ||
|
|
a4b1d7446a | ||
|
|
7458a6298f | ||
|
|
b0f54bb8b7 | ||
|
|
acddadc406 | ||
|
|
761fb20dd9 | ||
|
|
b74274b96b | ||
|
|
7835379f8f | ||
|
|
49ba278316 | ||
|
|
388058467c | ||
|
|
cf25bd7869 | ||
|
|
02a95345aa | ||
|
|
6076e2ed0a | ||
|
|
cec674cb47 | ||
|
|
c5a90823fa | ||
|
|
18d82bc1f0 | ||
|
|
a68af990ea | ||
|
|
e71c600d10 | ||
|
|
d7f1f7182c | ||
|
|
dfb2e460b4 | ||
|
|
5badef8ba9 | ||
|
|
18aa5ce75c | ||
|
|
1545a9f262 | ||
|
|
47cc65a787 | ||
|
|
cda9d5873d | ||
|
|
02cd553990 | ||
|
|
71d288f550 | ||
|
|
87df588c80 | ||
|
|
4ad2997717 | ||
|
|
50a03e7c15 | ||
|
|
4f3d12129c | ||
|
|
37a95980d4 | ||
|
|
f49806558e | ||
|
|
8da362d6fe | ||
|
|
bf02a59aec | ||
|
|
461777cad3 | ||
|
|
0597ba20d2 | ||
|
|
0b5fd27cd8 | ||
|
|
f5f8033d4d | ||
|
|
a5f7dec011 | ||
|
|
d9ef5a6612 | ||
|
|
66a81cd47c | ||
|
|
81edd13470 | ||
|
|
7a94745b8a | ||
|
|
06b02f5df8 | ||
|
|
83136e3142 | ||
|
|
950a9f2ee0 | ||
|
|
a26c10fee8 | ||
|
|
4bcd76fe93 | ||
|
|
90ccb091ca | ||
|
|
62df27eaa1 | ||
|
|
349115b948 | ||
|
|
4fd7e4be67 | ||
|
|
947e892916 | ||
|
|
d62b7d1a99 | ||
|
|
432b39a9c4 | ||
|
|
26540bfb63 | ||
|
|
fd64f88a7e | ||
|
|
72994bc9ef | ||
|
|
7e1138af50 | ||
|
|
72dbddb7f7 | ||
|
|
10dba50843 | ||
|
|
d6af1b5827 | ||
|
|
6c362a9b4b | ||
|
|
9a0584d649 | ||
|
|
5ab5211c95 | ||
|
|
f644682be7 | ||
|
|
ffad8e4d26 | ||
|
|
8f07e6304a | ||
|
|
834c03359f | ||
|
|
3e2c68ba49 | ||
|
|
2a21941b68 | ||
|
|
e78886fb35 | ||
|
|
80bf6a0c7a | ||
|
|
48e066b677 | ||
|
|
dcb9d7fc2a | ||
|
|
279f0f0234 | ||
|
|
b3c8a7d8de | ||
|
|
1baf1a79e5 | ||
|
|
35160e717e | ||
|
|
a12f2d8fbd | ||
|
|
6b7c17374b | ||
|
|
9b3585e795 | ||
|
|
74f383a7d4 | ||
|
|
820fbeed18 | ||
|
|
f76e8d9a77 | ||
|
|
5b85e60d5d | ||
|
|
24de670c2c | ||
|
|
42aca71763 | ||
|
|
9b4ef85174 | ||
|
|
9b389ffc33 | ||
|
|
b3cb81aa52 | ||
|
|
61865bc408 | ||
|
|
c2ea6214a9 | ||
|
|
b6684fe7a3 | ||
|
|
b50ebc05a0 | ||
|
|
dbb0648c39 | ||
|
|
5fc0987cc3 | ||
|
|
7c4037147c | ||
|
|
f76cb1231e | ||
|
|
6701d8c5e6 | ||
|
|
ff3d143185 | ||
|
|
ea95ab9062 | ||
|
|
38c901a1c5 | ||
|
|
0c9753b7cd | ||
|
|
721b36c7f7 | ||
|
|
f8e0716474 | ||
|
|
3d428ee844 | ||
|
|
a3be1fcd8f | ||
|
|
167f10c9f9 | ||
|
|
b3cabd9621 | ||
|
|
709468d281 | ||
|
|
c3a2bd70ff | ||
|
|
92caeed7ab | ||
|
|
3c91575ebe | ||
|
|
46a6223a43 | ||
|
|
e226c93eeb | ||
|
|
5aedce647f | ||
|
|
4881f7b01c | ||
|
|
bebe8c1b1d | ||
|
|
b03e8f7c71 | ||
|
|
fa0d5592d6 | ||
|
|
bcf3ce9adf | ||
|
|
14dd4f19aa | ||
|
|
cd86801eac | ||
|
|
da18e3312a | ||
|
|
fea56a0ddf | ||
|
|
d3cc52b794 | ||
|
|
f805b29a8c | ||
|
|
3f78e43bbf | ||
|
|
ab6670b3af | ||
|
|
797a160856 | ||
|
|
2d0935741c | ||
|
|
8a645cd47b | ||
|
|
f189694c78 | ||
|
|
63701c182a | ||
|
|
efd12dac35 | ||
|
|
e071b6c1b4 | ||
|
|
b590e889a7 | ||
|
|
17ea48f25d | ||
|
|
04fec4a585 | ||
|
|
ae06cf844d | ||
|
|
f3daa8e3bf | ||
|
|
3f0b80d48e | ||
|
|
4a5e3e433b | ||
|
|
5ffeac6683 | ||
|
|
71f2db30da | ||
|
|
61c6d01af2 | ||
|
|
30aedf04d7 | ||
|
|
f791c7eafd | ||
|
|
2f78c072d7 | ||
|
|
50c91b428d | ||
|
|
52abe0893a | ||
|
|
42f3f4403c | ||
|
|
0a1cc91c0c | ||
|
|
518cac7ab9 | ||
|
|
8c4a62b9c6 | ||
|
|
c1d1e923cd | ||
|
|
18e9aca3b1 | ||
|
|
9fe59f2949 | ||
|
|
ea5f7173bd | ||
|
|
d5611b185b | ||
|
|
b260029cd9 | ||
|
|
240b4b540b | ||
|
|
695302d407 | ||
|
|
be13400bc0 | ||
|
|
efc27192fa | ||
|
|
e1ede58094 | ||
|
|
ff21a50f7f | ||
|
|
4f5f65086f | ||
|
|
8b28866d53 | ||
|
|
77046000e8 | ||
|
|
48a6807851 | ||
|
|
5a46e09358 | ||
|
|
61d66dd8b3 | ||
|
|
2cb30b5f59 | ||
|
|
2568322879 | ||
|
|
8915149d36 | ||
|
|
300b7b9687 | ||
|
|
c782b38ba1 | ||
|
|
e6b65437e4 | ||
|
|
e6d148e729 | ||
|
|
dce9c4dccb | ||
|
|
ad6ae0b32a | ||
|
|
1dc3f85a66 | ||
|
|
cb7bf446e3 | ||
|
|
8d2e81815c | ||
|
|
cee57e4ffc | ||
|
|
475ada22e7 | ||
|
|
8847b5b674 | ||
|
|
73de429af1 | ||
|
|
d9b902f6ee | ||
|
|
0fcf0824dc | ||
|
|
9e07703eb1 | ||
|
|
9ae7b7773e | ||
|
|
d6037422ac | ||
|
|
38c8ceba12 |
13
.flake8
Normal file
13
.flake8
Normal file
@@ -0,0 +1,13 @@
|
||||
[flake8]
|
||||
max-line-length = 176
|
||||
select = E303,W293,W291,W292,E305,E231,E302
|
||||
exclude =
|
||||
.tox,
|
||||
__pycache__,
|
||||
*.pyc,
|
||||
.env
|
||||
venv/*
|
||||
.venv/*
|
||||
reports/*
|
||||
dist/*
|
||||
lib/*
|
||||
13
.github/ISSUE_TEMPLATE.md
vendored
13
.github/ISSUE_TEMPLATE.md
vendored
@@ -1,9 +1,12 @@
|
||||
### 前置确认
|
||||
|
||||
1. 网络能够访问openai接口
|
||||
2. python 已安装:版本在 3.7 ~ 3.10 之间,依赖已安装
|
||||
3. 在已有 issue 中未搜索到类似问题
|
||||
4. [FAQS](https://github.com/zhayujie/chatgpt-on-wechat/wiki/FAQs) 中无类似问题
|
||||
2. python 已安装:版本在 3.7 ~ 3.10 之间
|
||||
3. `git pull` 拉取最新代码
|
||||
4. 执行`pip3 install -r requirements.txt`,检查依赖是否满足
|
||||
5. 拓展功能请执行`pip3 install -r requirements-optional.txt`,检查依赖是否满足
|
||||
6. 在已有 issue 中未搜索到类似问题
|
||||
7. [FAQS](https://github.com/zhayujie/chatgpt-on-wechat/wiki/FAQs) 中无类似问题
|
||||
|
||||
|
||||
### 问题描述
|
||||
@@ -16,7 +19,7 @@
|
||||
### 终端日志 (如有报错)
|
||||
|
||||
```
|
||||
[在此处粘贴终端日志]
|
||||
[在此处粘贴终端日志, 可在主目录下`run.log`文件中找到]
|
||||
```
|
||||
|
||||
|
||||
@@ -24,5 +27,5 @@
|
||||
### 环境
|
||||
|
||||
- 操作系统类型 (Mac/Windows/Linux):
|
||||
- Python版本 ( 执行 `python3 -V` ):
|
||||
- Python版本 ( 执行 `python3 -V` ):
|
||||
- pip版本 ( 依赖问题此项必填,执行 `pip3 -V`):
|
||||
|
||||
59
.github/workflows/deploy-image.yml
vendored
Normal file
59
.github/workflows/deploy-image.yml
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
# This workflow uses actions that are not certified by GitHub.
|
||||
# They are provided by a third-party and are governed by
|
||||
# separate terms of service, privacy policy, and support
|
||||
# documentation.
|
||||
|
||||
# GitHub recommends pinning actions to a commit SHA.
|
||||
# To get a newer version, you will need to update the SHA.
|
||||
# You can also reference a tag or branch, but the action may change without warning.
|
||||
|
||||
name: Create and publish a Docker image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['master']
|
||||
create:
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-and-push-image:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
file: ./docker/Dockerfile.latest
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
- uses: actions/delete-package-versions@v4
|
||||
with:
|
||||
package-name: 'chatgpt-on-wechat'
|
||||
package-type: 'container'
|
||||
min-versions-to-keep: 10
|
||||
delete-only-untagged-versions: 'true'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
17
.gitignore
vendored
17
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
.DS_Store
|
||||
.idea
|
||||
.vscode
|
||||
.wechaty/
|
||||
__pycache__/
|
||||
venv*
|
||||
@@ -8,3 +9,19 @@ config.json
|
||||
QR.png
|
||||
nohup.out
|
||||
tmp
|
||||
plugins.json
|
||||
itchat.pkl
|
||||
*.log
|
||||
user_datas.pkl
|
||||
chatgpt_tool_hub/
|
||||
plugins/**/
|
||||
!plugins/bdunit
|
||||
!plugins/dungeon
|
||||
!plugins/finish
|
||||
!plugins/godcmd
|
||||
!plugins/tool
|
||||
!plugins/banwords
|
||||
!plugins/banwords/**/
|
||||
!plugins/hello
|
||||
!plugins/role
|
||||
!plugins/keyword
|
||||
30
.pre-commit-config.yaml
Normal file
30
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,30 @@
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.4.0
|
||||
hooks:
|
||||
- id: fix-byte-order-marker
|
||||
- id: check-case-conflict
|
||||
- id: check-merge-conflict
|
||||
- id: debug-statements
|
||||
- id: pretty-format-json
|
||||
types: [text]
|
||||
files: \.json(.template)?$
|
||||
args: [ --autofix , --no-ensure-ascii, --indent=2, --no-sort-keys]
|
||||
- id: trailing-whitespace
|
||||
exclude: '(\/|^)lib\/'
|
||||
args: [ --markdown-linebreak-ext=md ]
|
||||
- repo: https://github.com/PyCQA/isort
|
||||
rev: 5.12.0
|
||||
hooks:
|
||||
- id: isort
|
||||
exclude: '(\/|^)lib\/'
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 23.3.0
|
||||
hooks:
|
||||
- id: black
|
||||
exclude: '(\/|^)lib\/'
|
||||
- repo: https://github.com/PyCQA/flake8
|
||||
rev: 6.0.0
|
||||
hooks:
|
||||
- id: flake8
|
||||
exclude: '(\/|^)lib\/'
|
||||
3
Dockerfile
Normal file
3
Dockerfile
Normal file
@@ -0,0 +1,3 @@
|
||||
FROM ghcr.io/zhayujie/chatgpt-on-wechat:latest
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
97
README.md
97
README.md
@@ -2,31 +2,40 @@
|
||||
|
||||
> ChatGPT近期以强大的对话和信息整合能力风靡全网,可以写代码、改论文、讲故事,几乎无所不能,这让人不禁有个大胆的想法,能否用他的对话模型把我们的微信打造成一个智能机器人,可以在与好友对话中给出意想不到的回应,而且再也不用担心女朋友影响我们 ~~打游戏~~ 工作了。
|
||||
|
||||
|
||||
|
||||
基于ChatGPT的微信聊天机器人,通过 [ChatGPT](https://github.com/openai/openai-python) 接口生成对话内容,使用 [itchat](https://github.com/littlecodersh/ItChat) 实现微信消息的接收和自动回复。已实现的特性如下:
|
||||
|
||||
- [x] **文本对话:** 接收私聊及群组中的微信消息,使用ChatGPT生成回复内容,完成自动回复
|
||||
- [x] **规则定制化:** 支持私聊中按指定规则触发自动回复,支持对群组设置自动回复白名单
|
||||
- [x] **多账号:** 支持多微信账号同时运行
|
||||
- [x] **图片生成:** 支持根据描述生成图片,并自动发送至个人聊天或群聊
|
||||
- [x] **图片生成:** 支持根据描述生成图片,支持图片修复
|
||||
- [x] **上下文记忆**:支持多轮对话记忆,且为每个好友维护独立的上下会话
|
||||
- [x] **语音识别:** 支持接收和处理语音消息,通过文字或语音回复
|
||||
- [x] **插件化:** 支持个性化插件,提供角色扮演、文字冒险、与操作系统交互、访问网络数据等能力
|
||||
|
||||
> 目前支持微信和微信公众号部署,欢迎接入更多应用,参考 [Terminal代码](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/channel/terminal/terminal_channel.py)实现接收和发送消息逻辑即可接入。 同时欢迎增加新的插件,参考 [插件说明文档](https://github.com/zhayujie/chatgpt-on-wechat/tree/master/plugins)。
|
||||
|
||||
|
||||
**一键部署:**
|
||||
|
||||
[](https://railway.app/template/qApznZ?referralCode=RC3znh)
|
||||
|
||||
|
||||
# 更新日志
|
||||
|
||||
>**2023.03.09:** 基于 `whisper API` 实现对微信语音消息的解析和回复,添加配置项 `"speech_recognition":true` 即可启用,使用参考 [#415](https://github.com/zhayujie/chatgpt-on-wechat/issues/415)。(contributed by [wanggang1987](https://github.com/wanggang1987) in [#385](https://github.com/zhayujie/chatgpt-on-wechat/pull/385))
|
||||
>**2023.04.05:** 支持微信公众号部署,兼容角色扮演等预设插件,[使用文档](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/channel/wechatmp/README.md)。(contributed by [@JS00000](https://github.com/JS00000) in [#686](https://github.com/zhayujie/chatgpt-on-wechat/pull/686))
|
||||
|
||||
>**2023.04.05:** 增加能让ChatGPT使用工具的`tool`插件,[使用文档](https://github.com/goldfishh/chatgpt-on-wechat/blob/master/plugins/tool/README.md)。工具相关issue可反馈至[chatgpt-tool-hub](https://github.com/goldfishh/chatgpt-tool-hub)。(contributed by [@goldfishh](https://github.com/goldfishh) in [#663](https://github.com/zhayujie/chatgpt-on-wechat/pull/663))
|
||||
|
||||
>**2023.03.25:** 支持插件化开发,目前已实现 多角色切换、文字冒险游戏、管理员指令、Stable Diffusion等插件,使用参考 [#578](https://github.com/zhayujie/chatgpt-on-wechat/issues/578)。(contributed by [@lanvent](https://github.com/lanvent) in [#565](https://github.com/zhayujie/chatgpt-on-wechat/pull/565))
|
||||
|
||||
>**2023.03.09:** 基于 `whisper API`(后续已接入更多的语音`API`服务) 实现对微信语音消息的解析和回复,添加配置项 `"speech_recognition":true` 即可启用,使用参考 [#415](https://github.com/zhayujie/chatgpt-on-wechat/issues/415)。(contributed by [wanggang1987](https://github.com/wanggang1987) in [#385](https://github.com/zhayujie/chatgpt-on-wechat/pull/385))
|
||||
|
||||
>**2023.03.02:** 接入[ChatGPT API](https://platform.openai.com/docs/guides/chat) (gpt-3.5-turbo),默认使用该模型进行对话,需升级openai依赖 (`pip3 install --upgrade openai`)。网络问题参考 [#351](https://github.com/zhayujie/chatgpt-on-wechat/issues/351)
|
||||
|
||||
>**2023.02.20:** 增加 [python-wechaty](https://github.com/wechaty/python-wechaty) 作为可选渠道,使用Pad协议,但Token收费 (使用参考[#244](https://github.com/zhayujie/chatgpt-on-wechat/pull/244),contributed by [ZQ7](https://github.com/ZQ7))
|
||||
|
||||
>**2023.02.09:** 扫码登录存在封号风险,请谨慎使用,参考[#58](https://github.com/AutumnWhj/ChatGPT-wechat-bot/issues/158)
|
||||
|
||||
>**2023.02.05:** 在openai官方接口方案中 (GPT-3模型) 实现上下文对话
|
||||
|
||||
>**2022.12.19:** 引入 [itchat-uos](https://github.com/why2lyj/ItChat-UOS) 替换 itchat,解决由于不能登录网页微信而无法使用的问题,且解决Python3.9的兼容问题
|
||||
|
||||
>**2022.12.18:** 支持根据描述生成图片并发送,openai版本需大于0.25.0
|
||||
|
||||
>**2022.12.17:** 原来的方案是从 [ChatGPT页面](https://chat.openai.com/chat) 获取session_token,使用 [revChatGPT](https://github.com/acheong08/ChatGPT) 直接访问web接口,但随着ChatGPT接入Cloudflare人机验证,这一方案难以在服务器顺利运行。 所以目前使用的方案是调用 OpenAI 官方提供的 [API](https://beta.openai.com/docs/api-reference/introduction),回复质量上基本接近于ChatGPT的内容,劣势是暂不支持有上下文记忆的对话,优势是稳定性和响应速度较好。
|
||||
@@ -54,13 +63,12 @@
|
||||
|
||||
前往 [OpenAI注册页面](https://beta.openai.com/signup) 创建账号,参考这篇 [教程](https://www.pythonthree.com/register-openai-chatgpt/) 可以通过虚拟手机号来接收验证码。创建完账号则前往 [API管理页面](https://beta.openai.com/account/api-keys) 创建一个 API Key 并保存下来,后面需要在项目中配置这个key。
|
||||
|
||||
> 项目中使用的对话模型是 davinci,计费方式是约每 750 字 (包含请求和回复) 消耗 $0.02,图片生成是每张消耗 $0.016,账号创建有免费的 $18 额度,使用完可以更换邮箱重新注册。
|
||||
|
||||
> 项目中使用的对话模型是 davinci,计费方式是约每 750 字 (包含请求和回复) 消耗 $0.02,图片生成是每张消耗 $0.016,账号创建有免费的 $18 额度 (更新3.25: 最新注册的已经无免费额度了),使用完可以更换邮箱重新注册。
|
||||
|
||||
### 2.运行环境
|
||||
|
||||
支持 Linux、MacOS、Windows 系统(可在Linux服务器上长期运行),同时需安装 `Python`。
|
||||
> 建议Python版本在 3.7.1~3.9.X 之间,3.10及以上版本在 MacOS 可用,其他系统上不确定能否正常运行。
|
||||
支持 Linux、MacOS、Windows 系统(可在Linux服务器上长期运行),同时需安装 `Python`。
|
||||
> 建议Python版本在 3.7.1~3.9.X 之间,推荐3.8版本,3.10及以上版本在 MacOS 可用,其他系统上不确定能否正常运行。
|
||||
|
||||
**(1) 克隆项目代码:**
|
||||
|
||||
@@ -70,17 +78,35 @@ cd chatgpt-on-wechat/
|
||||
```
|
||||
|
||||
**(2) 安装核心依赖 (必选):**
|
||||
> 能够使用`itchat`创建机器人,并具有文字交流功能所需的最小依赖集合。
|
||||
```bash
|
||||
pip3 install -r requirements.txt
|
||||
```
|
||||
|
||||
**(3) 拓展依赖 (可选,建议安装):**
|
||||
|
||||
```bash
|
||||
pip3 install itchat-uos==1.5.0.dev0
|
||||
pip3 install --upgrade openai
|
||||
pip3 install -r requirements-optional.txt
|
||||
```
|
||||
注:`itchat-uos`使用指定版本1.5.0.dev0,`openai`使用最新版本,需高于0.27.0。
|
||||
> 如果某项依赖安装失败请注释掉对应的行再继续。
|
||||
|
||||
**(3) 拓展依赖 (可选):**
|
||||
其中`tiktoken`要求`python`版本在3.8以上,它用于精确计算会话使用的tokens数量,强烈建议安装。
|
||||
|
||||
语音识别及语音回复相关依赖:[#415](https://github.com/zhayujie/chatgpt-on-wechat/issues/415)。
|
||||
|
||||
使用`google`或`baidu`语音识别需安装`ffmpeg`,
|
||||
|
||||
默认的`openai`语音识别不需要安装`ffmpeg`。
|
||||
|
||||
参考[#415](https://github.com/zhayujie/chatgpt-on-wechat/issues/415)
|
||||
|
||||
使用`azure`语音功能需安装依赖(列在`requirements-optional.txt`内,但为便于`railway`部署已注释):
|
||||
|
||||
```bash
|
||||
pip3 install azure-cognitiveservices-speech
|
||||
```
|
||||
|
||||
> 目前默认发布的镜像和`railway`部署,都基于`apline`,无法安装`azure`的依赖。若有需求请自行基于[`debian`](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/docker/Dockerfile.debian.latest)打包。
|
||||
参考[文档](https://learn.microsoft.com/en-us/azure/cognitive-services/speech-service/quickstarts/setup-platform?pivots=programming-language-python&tabs=linux%2Cubuntu%2Cdotnet%2Cjre%2Cmaven%2Cnodejs%2Cmac%2Cpypi)
|
||||
|
||||
## 配置
|
||||
|
||||
@@ -94,18 +120,21 @@ pip3 install --upgrade openai
|
||||
|
||||
```bash
|
||||
# config.json文件内容示例
|
||||
{
|
||||
{
|
||||
"open_ai_api_key": "YOUR API KEY", # 填入上面创建的 OpenAI API KEY
|
||||
"model": "gpt-3.5-turbo", # 模型名称
|
||||
"model": "gpt-3.5-turbo", # 模型名称。当use_azure_chatgpt为true时,其名称为Azure上model deployment名称
|
||||
"proxy": "127.0.0.1:7890", # 代理客户端的ip和端口
|
||||
"single_chat_prefix": ["bot", "@bot"], # 私聊时文本需要包含该前缀才能触发机器人回复
|
||||
"single_chat_reply_prefix": "[bot] ", # 私聊时自动回复的前缀,用于区分真人
|
||||
"group_chat_prefix": ["@bot"], # 群聊时包含该前缀则会触发机器人回复
|
||||
"group_name_white_list": ["ChatGPT测试群", "ChatGPT测试群2"], # 开启自动回复的群名称列表
|
||||
"group_chat_in_one_session": ["ChatGPT测试群"], # 支持会话上下文共享的群名称
|
||||
"image_create_prefix": ["画", "看", "找"], # 开启图片回复的前缀
|
||||
"conversation_max_tokens": 1000, # 支持上下文记忆的最多字符数
|
||||
"speech_recognition": false, # 是否开启语音识别
|
||||
"character_desc": "你是ChatGPT, 一个由OpenAI训练的大型语言模型, 你旨在回答并解决人们的任何问题,并且可以使用多种语言与人交流。", # 人格描述
|
||||
"group_speech_recognition": false, # 是否开启群组语音识别
|
||||
"use_azure_chatgpt": false, # 是否使用Azure ChatGPT service代替openai ChatGPT service. 当设置为true时需要设置 open_ai_api_base,如 https://xxx.openai.azure.com/
|
||||
"character_desc": "你是ChatGPT, 一个由OpenAI训练的大型语言模型, 你旨在回答并解决人们的任何问题,并且可以使用多种语言与人交流。", # 人格描述,
|
||||
}
|
||||
```
|
||||
**配置说明:**
|
||||
@@ -120,16 +149,18 @@ pip3 install --upgrade openai
|
||||
+ 群组聊天中,群名称需配置在 `group_name_white_list ` 中才能开启群聊自动回复。如果想对所有群聊生效,可以直接填写 `"group_name_white_list": ["ALL_GROUP"]`
|
||||
+ 默认只要被人 @ 就会触发机器人自动回复;另外群聊天中只要检测到以 "@bot" 开头的内容,同样会自动回复(方便自己触发),这对应配置项 `group_chat_prefix`
|
||||
+ 可选配置: `group_name_keyword_white_list`配置项支持模糊匹配群名称,`group_chat_keyword`配置项则支持模糊匹配群消息内容,用法与上述两个配置项相同。(Contributed by [evolay](https://github.com/evolay))
|
||||
+ `group_chat_in_one_session`:使群聊共享一个会话上下文,配置 `["ALL_GROUP"]` 则作用于所有群聊
|
||||
|
||||
**3.语音识别**
|
||||
|
||||
+ 添加 `"speech_recognition": true` 将开启语音识别,默认使用openai的whisper模型识别为文字,同时以文字回复,目前只支持私聊 (注意由于语音消息无法匹配前缀,一旦开启将对所有语音自动回复);
|
||||
+ 添加 `"voice_reply_voice": true` 将开启语音回复语音,但是需要配置对应语音合成平台的key,由于itchat协议的限制,只能发送语音mp3文件,若使用wechaty则回复的是微信语音。
|
||||
+ 添加 `"speech_recognition": true` 将开启语音识别,默认使用openai的whisper模型识别为文字,同时以文字回复,该参数仅支持私聊 (注意由于语音消息无法匹配前缀,一旦开启将对所有语音自动回复,支持语音触发画图);
|
||||
+ 添加 `"group_speech_recognition": true` 将开启群组语音识别,默认使用openai的whisper模型识别为文字,同时以文字回复,参数仅支持群聊 (会匹配group_chat_prefix和group_chat_keyword, 支持语音触发画图);
|
||||
+ 添加 `"voice_reply_voice": true` 将开启语音回复语音(同时作用于私聊和群聊),但是需要配置对应语音合成平台的key,由于itchat协议的限制,只能发送语音mp3文件,若使用wechaty则回复的是微信语音。
|
||||
|
||||
**4.其他配置**
|
||||
|
||||
+ `model`: 模型名称,目前支持 `gpt-3.5-turbo`, `text-davinci-003`, `gpt-4`, `gpt-4-32k` (其中gpt-4 api暂未开放)
|
||||
+ `temperature`,`frequency_penalty`,`presence_penalty`: Chat API接口参数,详情参考[OpenAI官方文档。](https://platform.openai.com/docs/api-reference/chat)
|
||||
+ `temperature`,`frequency_penalty`,`presence_penalty`: Chat API接口参数,详情参考[OpenAI官方文档。](https://platform.openai.com/docs/api-reference/chat)
|
||||
+ `proxy`:由于目前 `openai` 接口国内无法访问,需配置代理客户端的地址,详情参考 [#351](https://github.com/zhayujie/chatgpt-on-wechat/issues/351)
|
||||
+ 对于图像生成,在满足个人或群组触发条件外,还需要额外的关键词前缀来触发,对应配置 `image_create_prefix `
|
||||
+ 关于OpenAI对话及图片接口的参数配置(内容自由度、回复字数限制、图片大小等),可以参考 [对话接口](https://beta.openai.com/docs/api-reference/completions) 和 [图像接口](https://beta.openai.com/docs/api-reference/completions) 文档直接在 [代码](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/bot/openai/open_ai_bot.py) `bot/openai/open_ai_bot.py` 中进行调整。
|
||||
@@ -139,6 +170,7 @@ pip3 install --upgrade openai
|
||||
+ `hot_reload`: 程序退出后,暂存微信扫码状态,默认关闭。
|
||||
+ `character_desc` 配置中保存着你对机器人说的一段话,他会记住这段话并作为他的设定,你可以为他定制任何人格 (关于会话上下文的更多内容参考该 [issue](https://github.com/zhayujie/chatgpt-on-wechat/issues/43))
|
||||
|
||||
**所有可选的配置项均在该[文件](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/config.py)中列出。**
|
||||
|
||||
## 运行
|
||||
|
||||
@@ -149,7 +181,7 @@ pip3 install --upgrade openai
|
||||
```bash
|
||||
python3 app.py
|
||||
```
|
||||
终端输出二维码后,使用微信进行扫码,当输出 "Start auto replying" 时表示自动回复程序已经成功运行了(注意:用于登录的微信需要在支付处已完成实名认证)。扫码登录后你的账号就成为机器人了,可以在微信手机端通过配置的关键词触发自动回复 (任意好友发送消息给你,或是自己发消息给好友),参考[#142](https://github.com/zhayujie/chatgpt-on-wechat/issues/142)。
|
||||
终端输出二维码后,使用微信进行扫码,当输出 "Start auto replying" 时表示自动回复程序已经成功运行了(注意:用于登录的微信需要在支付处已完成实名认证)。扫码登录后你的账号就成为机器人了,可以在微信手机端通过配置的关键词触发自动回复 (任意好友发送消息给你,或是自己发消息给好友),参考[#142](https://github.com/zhayujie/chatgpt-on-wechat/issues/142)。
|
||||
|
||||
|
||||
### 2.服务器部署
|
||||
@@ -157,22 +189,25 @@ python3 app.py
|
||||
使用nohup命令在后台运行程序:
|
||||
|
||||
```bash
|
||||
touch nohup.out # 首次运行需要新建日志文件
|
||||
touch nohup.out # 首次运行需要新建日志文件
|
||||
nohup python3 app.py & tail -f nohup.out # 在后台运行程序并通过日志输出二维码
|
||||
```
|
||||
扫码登录后程序即可运行于服务器后台,此时可通过 `ctrl+c` 关闭日志,不会影响后台程序的运行。使用 `ps -ef | grep app.py | grep -v grep` 命令可查看运行于后台的进程,如果想要重新启动程序可以先 `kill` 掉对应的进程。日志关闭后如果想要再次打开只需输入 `tail -f nohup.out`。此外,`scripts` 目录下有一键运行、关闭程序的脚本供使用。
|
||||
|
||||
> **注意:** 如果 扫码后手机提示登录验证需要等待5s,而终端的二维码再次刷新并提示 `Log in time out, reloading QR code`,此时需参考此 [issue](https://github.com/zhayujie/chatgpt-on-wechat/issues/8) 修改一行代码即可解决。
|
||||
> **多账号支持:** 将项目复制多份,分别启动程序,用不同账号扫码登录即可实现同时运行。
|
||||
|
||||
> **多账号支持:** 将 项目复制多份,分别启动程序,用不同账号扫码登录即可实现同时运行。
|
||||
|
||||
> **特殊指令:** 用户向机器人发送 **#清除记忆** 即可清空该用户的上下文记忆。
|
||||
> **特殊指令:** 用户向机器人发送 **#reset** 即可清空该用户的上下文记忆。
|
||||
|
||||
|
||||
### 3.Docker部署
|
||||
|
||||
参考文档 [Docker部署](https://github.com/limccn/chatgpt-on-wechat/wiki/Docker%E9%83%A8%E7%BD%B2) (Contributed by [limccn](https://github.com/limccn))。
|
||||
|
||||
### 4. Railway部署(✅推荐)
|
||||
> Railway每月提供5刀和最多500小时的免费额度。
|
||||
1. 进入 [Railway](https://railway.app/template/qApznZ?referralCode=RC3znh)。
|
||||
2. 点击 `Deploy Now` 按钮。
|
||||
3. 设置环境变量来重载程序运行的参数,例如`open_ai_api_key`, `character_desc`。
|
||||
|
||||
## 常见问题
|
||||
|
||||
@@ -181,6 +216,6 @@ FAQs: <https://github.com/zhayujie/chatgpt-on-wechat/wiki/FAQs>
|
||||
|
||||
## 联系
|
||||
|
||||
欢迎提交PR、Issues,以及Star支持一下。程序运行遇到问题优先查看 [常见问题列表](https://github.com/zhayujie/chatgpt-on-wechat/wiki/FAQs) ,其次前往 [Issues](https://github.com/zhayujie/chatgpt-on-wechat/issues) 中搜索,若无相似问题可创建Issue,或加微信 eijuyahz 交流。
|
||||
欢迎提交PR、Issues,以及Star支持一下。程序运行遇到问题优先查看 [常见问题列表](https://github.com/zhayujie/chatgpt-on-wechat/wiki/FAQs) ,其次前往 [Issues](https://github.com/zhayujie/chatgpt-on-wechat/issues) 中搜索。如果你想了解更多项目细节,并与开发者们交流更多关于AI技术的实践,欢迎加入星球:
|
||||
|
||||
|
||||
<a href="https://public.zsxq.com/groups/88885848842852.html"><img width="360" src="./docs/images/planet.jpg"></a>
|
||||
|
||||
45
app.py
45
app.py
@@ -1,20 +1,57 @@
|
||||
# encoding:utf-8
|
||||
|
||||
import config
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
|
||||
from channel import channel_factory
|
||||
from common.log import logger
|
||||
from config import conf, load_config
|
||||
from plugins import *
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
def sigterm_handler_wrap(_signo):
|
||||
old_handler = signal.getsignal(_signo)
|
||||
|
||||
def func(_signo, _stack_frame):
|
||||
logger.info("signal {} received, exiting...".format(_signo))
|
||||
conf().save_user_datas()
|
||||
if callable(old_handler): # check old_handler
|
||||
return old_handler(_signo, _stack_frame)
|
||||
sys.exit(0)
|
||||
|
||||
signal.signal(_signo, func)
|
||||
|
||||
|
||||
def run():
|
||||
try:
|
||||
# load config
|
||||
config.load_config()
|
||||
load_config()
|
||||
# ctrl + c
|
||||
sigterm_handler_wrap(signal.SIGINT)
|
||||
# kill signal
|
||||
sigterm_handler_wrap(signal.SIGTERM)
|
||||
|
||||
# create channel
|
||||
channel = channel_factory.create_channel("wx")
|
||||
channel_name = conf().get("channel_type", "wx")
|
||||
|
||||
if "--cmd" in sys.argv:
|
||||
channel_name = "terminal"
|
||||
|
||||
if channel_name == "wxy":
|
||||
os.environ["WECHATY_LOG"] = "warn"
|
||||
# os.environ['WECHATY_PUPPET_SERVICE_ENDPOINT'] = '127.0.0.1:9001'
|
||||
|
||||
channel = channel_factory.create_channel(channel_name)
|
||||
if channel_name in ["wx", "wxy", "terminal", "wechatmp", "wechatmp_service"]:
|
||||
PluginManager().load_plugins()
|
||||
|
||||
# startup channel
|
||||
channel.startup()
|
||||
except Exception as e:
|
||||
logger.error("App startup failed!")
|
||||
logger.exception(e)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
|
||||
@@ -1,26 +1,36 @@
|
||||
# encoding:utf-8
|
||||
|
||||
import requests
|
||||
|
||||
from bot.bot import Bot
|
||||
from bridge.reply import Reply, ReplyType
|
||||
|
||||
|
||||
# Baidu Unit对话接口 (可用, 但能力较弱)
|
||||
class BaiduUnitBot(Bot):
|
||||
def reply(self, query, context=None):
|
||||
token = self.get_token()
|
||||
url = 'https://aip.baidubce.com/rpc/2.0/unit/service/v3/chat?access_token=' + token
|
||||
post_data = "{\"version\":\"3.0\",\"service_id\":\"S73177\",\"session_id\":\"\",\"log_id\":\"7758521\",\"skill_ids\":[\"1221886\"],\"request\":{\"terminal_id\":\"88888\",\"query\":\"" + query + "\", \"hyper_params\": {\"chat_custom_bot_profile\": 1}}}"
|
||||
url = "https://aip.baidubce.com/rpc/2.0/unit/service/v3/chat?access_token=" + token
|
||||
post_data = (
|
||||
'{"version":"3.0","service_id":"S73177","session_id":"","log_id":"7758521","skill_ids":["1221886"],"request":{"terminal_id":"88888","query":"'
|
||||
+ query
|
||||
+ '", "hyper_params": {"chat_custom_bot_profile": 1}}}'
|
||||
)
|
||||
print(post_data)
|
||||
headers = {'content-type': 'application/x-www-form-urlencoded'}
|
||||
headers = {"content-type": "application/x-www-form-urlencoded"}
|
||||
response = requests.post(url, data=post_data.encode(), headers=headers)
|
||||
if response:
|
||||
return response.json()['result']['context']['SYS_PRESUMED_HIST'][1]
|
||||
reply = Reply(
|
||||
ReplyType.TEXT,
|
||||
response.json()["result"]["context"]["SYS_PRESUMED_HIST"][1],
|
||||
)
|
||||
return reply
|
||||
|
||||
def get_token(self):
|
||||
access_key = 'YOUR_ACCESS_KEY'
|
||||
secret_key = 'YOUR_SECRET_KEY'
|
||||
host = 'https://aip.baidubce.com/oauth/2.0/token?grant_type=client_credentials&client_id=' + access_key + '&client_secret=' + secret_key
|
||||
access_key = "YOUR_ACCESS_KEY"
|
||||
secret_key = "YOUR_SECRET_KEY"
|
||||
host = "https://aip.baidubce.com/oauth/2.0/token?grant_type=client_credentials&client_id=" + access_key + "&client_secret=" + secret_key
|
||||
response = requests.get(host)
|
||||
if response:
|
||||
print(response.json())
|
||||
return response.json()['access_token']
|
||||
return response.json()["access_token"]
|
||||
|
||||
@@ -3,8 +3,12 @@ Auto-replay chat robot abstract class
|
||||
"""
|
||||
|
||||
|
||||
from bridge.context import Context
|
||||
from bridge.reply import Reply
|
||||
|
||||
|
||||
class Bot(object):
|
||||
def reply(self, query, context=None):
|
||||
def reply(self, query, context: Context = None) -> Reply:
|
||||
"""
|
||||
bot auto-reply content
|
||||
:param req: received message
|
||||
|
||||
@@ -6,22 +6,31 @@ from common import const
|
||||
|
||||
def create_bot(bot_type):
|
||||
"""
|
||||
create a channel instance
|
||||
:param channel_type: channel type code
|
||||
:return: channel instance
|
||||
create a bot_type instance
|
||||
:param bot_type: bot type code
|
||||
:return: bot instance
|
||||
"""
|
||||
if bot_type == const.BAIDU:
|
||||
# Baidu Unit对话接口
|
||||
from bot.baidu.baidu_unit_bot import BaiduUnitBot
|
||||
|
||||
return BaiduUnitBot()
|
||||
|
||||
elif bot_type == const.CHATGPT:
|
||||
# ChatGPT 网页端web接口
|
||||
from bot.chatgpt.chat_gpt_bot import ChatGPTBot
|
||||
|
||||
return ChatGPTBot()
|
||||
|
||||
elif bot_type == const.OPEN_AI:
|
||||
# OpenAI 官方对话模型API
|
||||
from bot.openai.open_ai_bot import OpenAIBot
|
||||
|
||||
return OpenAIBot()
|
||||
|
||||
elif bot_type == const.CHATGPTONAZURE:
|
||||
# Azure chatgpt service https://azure.microsoft.com/en-in/products/cognitive-services/openai-service/
|
||||
from bot.chatgpt.chat_gpt_bot import AzureChatGPTBot
|
||||
|
||||
return AzureChatGPTBot()
|
||||
raise RuntimeError
|
||||
|
||||
@@ -1,197 +1,157 @@
|
||||
# encoding:utf-8
|
||||
|
||||
from bot.bot import Bot
|
||||
from config import conf, load_config
|
||||
from common.log import logger
|
||||
from common.token_bucket import TokenBucket
|
||||
from common.expired_dict import ExpiredDict
|
||||
import openai
|
||||
import time
|
||||
|
||||
if conf().get('expires_in_seconds'):
|
||||
all_sessions = ExpiredDict(conf().get('expires_in_seconds'))
|
||||
else:
|
||||
all_sessions = dict()
|
||||
import openai
|
||||
import openai.error
|
||||
|
||||
from bot.bot import Bot
|
||||
from bot.chatgpt.chat_gpt_session import ChatGPTSession
|
||||
from bot.openai.open_ai_image import OpenAIImage
|
||||
from bot.session_manager import SessionManager
|
||||
from bridge.context import ContextType
|
||||
from bridge.reply import Reply, ReplyType
|
||||
from common.log import logger
|
||||
from common.token_bucket import TokenBucket
|
||||
from config import conf, load_config
|
||||
|
||||
|
||||
# OpenAI对话模型API (可用)
|
||||
class ChatGPTBot(Bot):
|
||||
class ChatGPTBot(Bot, OpenAIImage):
|
||||
def __init__(self):
|
||||
openai.api_key = conf().get('open_ai_api_key')
|
||||
if conf().get('open_ai_api_base'):
|
||||
openai.api_base = conf().get('open_ai_api_base')
|
||||
proxy = conf().get('proxy')
|
||||
super().__init__()
|
||||
# set the default api_key
|
||||
openai.api_key = conf().get("open_ai_api_key")
|
||||
if conf().get("open_ai_api_base"):
|
||||
openai.api_base = conf().get("open_ai_api_base")
|
||||
proxy = conf().get("proxy")
|
||||
if proxy:
|
||||
openai.proxy = proxy
|
||||
if conf().get('rate_limit_chatgpt'):
|
||||
self.tb4chatgpt = TokenBucket(conf().get('rate_limit_chatgpt', 20))
|
||||
if conf().get('rate_limit_dalle'):
|
||||
self.tb4dalle = TokenBucket(conf().get('rate_limit_dalle', 50))
|
||||
if conf().get("rate_limit_chatgpt"):
|
||||
self.tb4chatgpt = TokenBucket(conf().get("rate_limit_chatgpt", 20))
|
||||
|
||||
self.sessions = SessionManager(ChatGPTSession, model=conf().get("model") or "gpt-3.5-turbo")
|
||||
self.args = {
|
||||
"model": conf().get("model") or "gpt-3.5-turbo", # 对话模型的名称
|
||||
"temperature": conf().get("temperature", 0.9), # 值在[0,1]之间,越大表示回复越具有不确定性
|
||||
# "max_tokens":4096, # 回复最大的字符数
|
||||
"top_p": 1,
|
||||
"frequency_penalty": conf().get("frequency_penalty", 0.0), # [-2,2]之间,该值越大则更倾向于产生不同的内容
|
||||
"presence_penalty": conf().get("presence_penalty", 0.0), # [-2,2]之间,该值越大则更倾向于产生不同的内容
|
||||
"request_timeout": conf().get("request_timeout", None), # 请求超时时间,openai接口默认设置为600,对于难问题一般需要较长时间
|
||||
"timeout": conf().get("request_timeout", None), # 重试超时时间,在这个时间内,将会自动重试
|
||||
}
|
||||
|
||||
def reply(self, query, context=None):
|
||||
# acquire reply content
|
||||
if not context or not context.get('type') or context.get('type') == 'TEXT':
|
||||
logger.info("[OPEN_AI] query={}".format(query))
|
||||
session_id = context.get('session_id') or context.get('from_user_id')
|
||||
clear_memory_commands = conf().get('clear_memory_commands', ['#清除记忆'])
|
||||
if query in clear_memory_commands:
|
||||
Session.clear_session(session_id)
|
||||
return '记忆已清除'
|
||||
elif query == '#清除所有':
|
||||
Session.clear_all_session()
|
||||
return '所有人记忆已清除'
|
||||
elif query == '#更新配置':
|
||||
load_config()
|
||||
return '配置已更新'
|
||||
if context.type == ContextType.TEXT:
|
||||
logger.info("[CHATGPT] query={}".format(query))
|
||||
|
||||
session = Session.build_session_query(query, session_id)
|
||||
logger.debug("[OPEN_AI] session query={}".format(session))
|
||||
session_id = context["session_id"]
|
||||
reply = None
|
||||
clear_memory_commands = conf().get("clear_memory_commands", ["#清除记忆"])
|
||||
if query in clear_memory_commands:
|
||||
self.sessions.clear_session(session_id)
|
||||
reply = Reply(ReplyType.INFO, "记忆已清除")
|
||||
elif query == "#清除所有":
|
||||
self.sessions.clear_all_session()
|
||||
reply = Reply(ReplyType.INFO, "所有人记忆已清除")
|
||||
elif query == "#更新配置":
|
||||
load_config()
|
||||
reply = Reply(ReplyType.INFO, "配置已更新")
|
||||
if reply:
|
||||
return reply
|
||||
session = self.sessions.session_query(query, session_id)
|
||||
logger.debug("[CHATGPT] session query={}".format(session.messages))
|
||||
|
||||
api_key = context.get("openai_api_key")
|
||||
|
||||
# if context.get('stream'):
|
||||
# # reply in stream
|
||||
# return self.reply_text_stream(query, new_query, session_id)
|
||||
|
||||
reply_content = self.reply_text(session, session_id, 0)
|
||||
logger.debug("[OPEN_AI] new_query={}, session_id={}, reply_cont={}".format(session, session_id, reply_content["content"]))
|
||||
if reply_content["completion_tokens"] > 0:
|
||||
Session.save_session(reply_content["content"], session_id, reply_content["total_tokens"])
|
||||
return reply_content["content"]
|
||||
reply_content = self.reply_text(session, api_key)
|
||||
logger.debug(
|
||||
"[CHATGPT] new_query={}, session_id={}, reply_cont={}, completion_tokens={}".format(
|
||||
session.messages,
|
||||
session_id,
|
||||
reply_content["content"],
|
||||
reply_content["completion_tokens"],
|
||||
)
|
||||
)
|
||||
if reply_content["completion_tokens"] == 0 and len(reply_content["content"]) > 0:
|
||||
reply = Reply(ReplyType.ERROR, reply_content["content"])
|
||||
elif reply_content["completion_tokens"] > 0:
|
||||
self.sessions.session_reply(reply_content["content"], session_id, reply_content["total_tokens"])
|
||||
reply = Reply(ReplyType.TEXT, reply_content["content"])
|
||||
else:
|
||||
reply = Reply(ReplyType.ERROR, reply_content["content"])
|
||||
logger.debug("[CHATGPT] reply {} used 0 tokens.".format(reply_content))
|
||||
return reply
|
||||
|
||||
elif context.get('type', None) == 'IMAGE_CREATE':
|
||||
return self.create_img(query, 0)
|
||||
elif context.type == ContextType.IMAGE_CREATE:
|
||||
ok, retstring = self.create_img(query, 0)
|
||||
reply = None
|
||||
if ok:
|
||||
reply = Reply(ReplyType.IMAGE_URL, retstring)
|
||||
else:
|
||||
reply = Reply(ReplyType.ERROR, retstring)
|
||||
return reply
|
||||
else:
|
||||
reply = Reply(ReplyType.ERROR, "Bot不支持处理{}类型的消息".format(context.type))
|
||||
return reply
|
||||
|
||||
def reply_text(self, session, session_id, retry_count=0) ->dict:
|
||||
'''
|
||||
def reply_text(self, session: ChatGPTSession, api_key=None, retry_count=0) -> dict:
|
||||
"""
|
||||
call openai's ChatCompletion to get the answer
|
||||
:param session: a conversation session
|
||||
:param session_id: session id
|
||||
:param retry_count: retry count
|
||||
:return: {}
|
||||
'''
|
||||
"""
|
||||
try:
|
||||
if conf().get('rate_limit_chatgpt') and not self.tb4chatgpt.get_token():
|
||||
return {"completion_tokens": 0, "content": "提问太快啦,请休息一下再问我吧"}
|
||||
response = openai.ChatCompletion.create(
|
||||
model= conf().get("model") or "gpt-3.5-turbo", # 对话模型的名称
|
||||
messages=session,
|
||||
temperature=conf().get('temperature', 0.9), # 值在[0,1]之间,越大表示回复越具有不确定性
|
||||
#max_tokens=4096, # 回复最大的字符数
|
||||
top_p=1,
|
||||
frequency_penalty=conf().get('frequency_penalty', 0.0), # [-2,2]之间,该值越大则更倾向于产生不同的内容
|
||||
presence_penalty=conf().get('presence_penalty', 0.0), # [-2,2]之间,该值越大则更倾向于产生不同的内容
|
||||
)
|
||||
if conf().get("rate_limit_chatgpt") and not self.tb4chatgpt.get_token():
|
||||
raise openai.error.RateLimitError("RateLimitError: rate limit exceeded")
|
||||
# if api_key == None, the default openai.api_key will be used
|
||||
response = openai.ChatCompletion.create(api_key=api_key, messages=session.messages, **self.args)
|
||||
# logger.info("[ChatGPT] reply={}, total_tokens={}".format(response.choices[0]['message']['content'], response["usage"]["total_tokens"]))
|
||||
return {"total_tokens": response["usage"]["total_tokens"],
|
||||
"completion_tokens": response["usage"]["completion_tokens"],
|
||||
"content": response.choices[0]['message']['content']}
|
||||
except openai.error.RateLimitError as e:
|
||||
# rate limit exception
|
||||
logger.warn(e)
|
||||
if retry_count < 1:
|
||||
time.sleep(5)
|
||||
logger.warn("[OPEN_AI] RateLimit exceed, 第{}次重试".format(retry_count+1))
|
||||
return self.reply_text(session, session_id, retry_count+1)
|
||||
else:
|
||||
return {"completion_tokens": 0, "content": "提问太快啦,请休息一下再问我吧"}
|
||||
except openai.error.APIConnectionError as e:
|
||||
# api connection exception
|
||||
logger.warn(e)
|
||||
logger.warn("[OPEN_AI] APIConnection failed")
|
||||
return {"completion_tokens": 0, "content":"我连接不到你的网络"}
|
||||
except openai.error.Timeout as e:
|
||||
logger.warn(e)
|
||||
logger.warn("[OPEN_AI] Timeout")
|
||||
return {"completion_tokens": 0, "content":"我没有收到你的消息"}
|
||||
return {
|
||||
"total_tokens": response["usage"]["total_tokens"],
|
||||
"completion_tokens": response["usage"]["completion_tokens"],
|
||||
"content": response.choices[0]["message"]["content"],
|
||||
}
|
||||
except Exception as e:
|
||||
# unknown exception
|
||||
logger.exception(e)
|
||||
Session.clear_session(session_id)
|
||||
return {"completion_tokens": 0, "content": "请再问我一次吧"}
|
||||
|
||||
def create_img(self, query, retry_count=0):
|
||||
try:
|
||||
if conf().get('rate_limit_dalle') and not self.tb4dalle.get_token():
|
||||
return "请求太快了,请休息一下再问我吧"
|
||||
logger.info("[OPEN_AI] image_query={}".format(query))
|
||||
response = openai.Image.create(
|
||||
prompt=query, #图片描述
|
||||
n=1, #每次生成图片的数量
|
||||
size="256x256" #图片大小,可选有 256x256, 512x512, 1024x1024
|
||||
)
|
||||
image_url = response['data'][0]['url']
|
||||
logger.info("[OPEN_AI] image_url={}".format(image_url))
|
||||
return image_url
|
||||
except openai.error.RateLimitError as e:
|
||||
logger.warn(e)
|
||||
if retry_count < 1:
|
||||
time.sleep(5)
|
||||
logger.warn("[OPEN_AI] ImgCreate RateLimit exceed, 第{}次重试".format(retry_count+1))
|
||||
return self.create_img(query, retry_count+1)
|
||||
need_retry = retry_count < 2
|
||||
result = {"completion_tokens": 0, "content": "我现在有点累了,等会再来吧"}
|
||||
if isinstance(e, openai.error.RateLimitError):
|
||||
logger.warn("[CHATGPT] RateLimitError: {}".format(e))
|
||||
result["content"] = "提问太快啦,请休息一下再问我吧"
|
||||
if need_retry:
|
||||
time.sleep(20)
|
||||
elif isinstance(e, openai.error.Timeout):
|
||||
logger.warn("[CHATGPT] Timeout: {}".format(e))
|
||||
result["content"] = "我没有收到你的消息"
|
||||
if need_retry:
|
||||
time.sleep(5)
|
||||
elif isinstance(e, openai.error.APIConnectionError):
|
||||
logger.warn("[CHATGPT] APIConnectionError: {}".format(e))
|
||||
need_retry = False
|
||||
result["content"] = "我连接不到你的网络"
|
||||
else:
|
||||
return "请求太快啦,请休息一下再问我吧"
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
return None
|
||||
logger.warn("[CHATGPT] Exception: {}".format(e))
|
||||
need_retry = False
|
||||
self.sessions.clear_session(session.session_id)
|
||||
|
||||
class Session(object):
|
||||
@staticmethod
|
||||
def build_session_query(query, session_id):
|
||||
'''
|
||||
build query with conversation history
|
||||
e.g. [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "Who won the world series in 2020?"},
|
||||
{"role": "assistant", "content": "The Los Angeles Dodgers won the World Series in 2020."},
|
||||
{"role": "user", "content": "Where was it played?"}
|
||||
]
|
||||
:param query: query content
|
||||
:param session_id: session id
|
||||
:return: query content with conversaction
|
||||
'''
|
||||
session = all_sessions.get(session_id, [])
|
||||
if len(session) == 0:
|
||||
system_prompt = conf().get("character_desc", "")
|
||||
system_item = {'role': 'system', 'content': system_prompt}
|
||||
session.append(system_item)
|
||||
all_sessions[session_id] = session
|
||||
user_item = {'role': 'user', 'content': query}
|
||||
session.append(user_item)
|
||||
return session
|
||||
|
||||
@staticmethod
|
||||
def save_session(answer, session_id, total_tokens):
|
||||
max_tokens = conf().get("conversation_max_tokens")
|
||||
if not max_tokens:
|
||||
# default 3000
|
||||
max_tokens = 1000
|
||||
max_tokens=int(max_tokens)
|
||||
|
||||
session = all_sessions.get(session_id)
|
||||
if session:
|
||||
# append conversation
|
||||
gpt_item = {'role': 'assistant', 'content': answer}
|
||||
session.append(gpt_item)
|
||||
|
||||
# discard exceed limit conversation
|
||||
Session.discard_exceed_conversation(session, max_tokens, total_tokens)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def discard_exceed_conversation(session, max_tokens, total_tokens):
|
||||
dec_tokens = int(total_tokens)
|
||||
# logger.info("prompt tokens used={},max_tokens={}".format(used_tokens,max_tokens))
|
||||
while dec_tokens > max_tokens:
|
||||
# pop first conversation
|
||||
if len(session) > 3:
|
||||
session.pop(1)
|
||||
session.pop(1)
|
||||
if need_retry:
|
||||
logger.warn("[CHATGPT] 第{}次重试".format(retry_count + 1))
|
||||
return self.reply_text(session, api_key, retry_count + 1)
|
||||
else:
|
||||
break
|
||||
dec_tokens = dec_tokens - max_tokens
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def clear_session(session_id):
|
||||
all_sessions[session_id] = []
|
||||
|
||||
@staticmethod
|
||||
def clear_all_session():
|
||||
all_sessions.clear()
|
||||
class AzureChatGPTBot(ChatGPTBot):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
openai.api_type = "azure"
|
||||
openai.api_version = "2023-03-15-preview"
|
||||
self.args["deployment_id"] = conf().get("azure_deployment_id")
|
||||
|
||||
86
bot/chatgpt/chat_gpt_session.py
Normal file
86
bot/chatgpt/chat_gpt_session.py
Normal file
@@ -0,0 +1,86 @@
|
||||
from bot.session_manager import Session
|
||||
from common.log import logger
|
||||
|
||||
"""
|
||||
e.g. [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "Who won the world series in 2020?"},
|
||||
{"role": "assistant", "content": "The Los Angeles Dodgers won the World Series in 2020."},
|
||||
{"role": "user", "content": "Where was it played?"}
|
||||
]
|
||||
"""
|
||||
|
||||
|
||||
class ChatGPTSession(Session):
|
||||
def __init__(self, session_id, system_prompt=None, model="gpt-3.5-turbo"):
|
||||
super().__init__(session_id, system_prompt)
|
||||
self.model = model
|
||||
self.reset()
|
||||
|
||||
def discard_exceeding(self, max_tokens, cur_tokens=None):
|
||||
precise = True
|
||||
try:
|
||||
cur_tokens = self.calc_tokens()
|
||||
except Exception as e:
|
||||
precise = False
|
||||
if cur_tokens is None:
|
||||
raise e
|
||||
logger.debug("Exception when counting tokens precisely for query: {}".format(e))
|
||||
while cur_tokens > max_tokens:
|
||||
if len(self.messages) > 2:
|
||||
self.messages.pop(1)
|
||||
elif len(self.messages) == 2 and self.messages[1]["role"] == "assistant":
|
||||
self.messages.pop(1)
|
||||
if precise:
|
||||
cur_tokens = self.calc_tokens()
|
||||
else:
|
||||
cur_tokens = cur_tokens - max_tokens
|
||||
break
|
||||
elif len(self.messages) == 2 and self.messages[1]["role"] == "user":
|
||||
logger.warn("user message exceed max_tokens. total_tokens={}".format(cur_tokens))
|
||||
break
|
||||
else:
|
||||
logger.debug("max_tokens={}, total_tokens={}, len(messages)={}".format(max_tokens, cur_tokens, len(self.messages)))
|
||||
break
|
||||
if precise:
|
||||
cur_tokens = self.calc_tokens()
|
||||
else:
|
||||
cur_tokens = cur_tokens - max_tokens
|
||||
return cur_tokens
|
||||
|
||||
def calc_tokens(self):
|
||||
return num_tokens_from_messages(self.messages, self.model)
|
||||
|
||||
|
||||
# refer to https://github.com/openai/openai-cookbook/blob/main/examples/How_to_count_tokens_with_tiktoken.ipynb
|
||||
def num_tokens_from_messages(messages, model):
|
||||
"""Returns the number of tokens used by a list of messages."""
|
||||
import tiktoken
|
||||
|
||||
try:
|
||||
encoding = tiktoken.encoding_for_model(model)
|
||||
except KeyError:
|
||||
logger.debug("Warning: model not found. Using cl100k_base encoding.")
|
||||
encoding = tiktoken.get_encoding("cl100k_base")
|
||||
if model == "gpt-3.5-turbo" or model == "gpt-35-turbo":
|
||||
return num_tokens_from_messages(messages, model="gpt-3.5-turbo-0301")
|
||||
elif model == "gpt-4":
|
||||
return num_tokens_from_messages(messages, model="gpt-4-0314")
|
||||
elif model == "gpt-3.5-turbo-0301":
|
||||
tokens_per_message = 4 # every message follows <|start|>{role/name}\n{content}<|end|>\n
|
||||
tokens_per_name = -1 # if there's a name, the role is omitted
|
||||
elif model == "gpt-4-0314":
|
||||
tokens_per_message = 3
|
||||
tokens_per_name = 1
|
||||
else:
|
||||
logger.warn(f"num_tokens_from_messages() is not implemented for model {model}. Returning num tokens assuming gpt-3.5-turbo-0301.")
|
||||
return num_tokens_from_messages(messages, model="gpt-3.5-turbo-0301")
|
||||
num_tokens = 0
|
||||
for message in messages:
|
||||
num_tokens += tokens_per_message
|
||||
for key, value in message.items():
|
||||
num_tokens += len(encoding.encode(value))
|
||||
if key == "name":
|
||||
num_tokens += tokens_per_name
|
||||
num_tokens += 3 # every reply is primed with <|start|>assistant<|message|>
|
||||
return num_tokens
|
||||
@@ -1,170 +1,122 @@
|
||||
# encoding:utf-8
|
||||
|
||||
from bot.bot import Bot
|
||||
from config import conf
|
||||
from common.log import logger
|
||||
import openai
|
||||
import time
|
||||
|
||||
import openai
|
||||
import openai.error
|
||||
|
||||
from bot.bot import Bot
|
||||
from bot.openai.open_ai_image import OpenAIImage
|
||||
from bot.openai.open_ai_session import OpenAISession
|
||||
from bot.session_manager import SessionManager
|
||||
from bridge.context import ContextType
|
||||
from bridge.reply import Reply, ReplyType
|
||||
from common.log import logger
|
||||
from config import conf
|
||||
|
||||
user_session = dict()
|
||||
|
||||
|
||||
# OpenAI对话模型API (可用)
|
||||
class OpenAIBot(Bot):
|
||||
class OpenAIBot(Bot, OpenAIImage):
|
||||
def __init__(self):
|
||||
openai.api_key = conf().get('open_ai_api_key')
|
||||
if conf().get('open_ai_api_base'):
|
||||
openai.api_base = conf().get('open_ai_api_base')
|
||||
proxy = conf().get('proxy')
|
||||
super().__init__()
|
||||
openai.api_key = conf().get("open_ai_api_key")
|
||||
if conf().get("open_ai_api_base"):
|
||||
openai.api_base = conf().get("open_ai_api_base")
|
||||
proxy = conf().get("proxy")
|
||||
if proxy:
|
||||
openai.proxy = proxy
|
||||
|
||||
self.sessions = SessionManager(OpenAISession, model=conf().get("model") or "text-davinci-003")
|
||||
self.args = {
|
||||
"model": conf().get("model") or "text-davinci-003", # 对话模型的名称
|
||||
"temperature": conf().get("temperature", 0.9), # 值在[0,1]之间,越大表示回复越具有不确定性
|
||||
"max_tokens": 1200, # 回复最大的字符数
|
||||
"top_p": 1,
|
||||
"frequency_penalty": conf().get("frequency_penalty", 0.0), # [-2,2]之间,该值越大则更倾向于产生不同的内容
|
||||
"presence_penalty": conf().get("presence_penalty", 0.0), # [-2,2]之间,该值越大则更倾向于产生不同的内容
|
||||
"request_timeout": conf().get("request_timeout", None), # 请求超时时间,openai接口默认设置为600,对于难问题一般需要较长时间
|
||||
"timeout": conf().get("request_timeout", None), # 重试超时时间,在这个时间内,将会自动重试
|
||||
"stop": ["\n\n\n"],
|
||||
}
|
||||
|
||||
def reply(self, query, context=None):
|
||||
# acquire reply content
|
||||
if not context or not context.get('type') or context.get('type') == 'TEXT':
|
||||
logger.info("[OPEN_AI] query={}".format(query))
|
||||
from_user_id = context.get('from_user_id') or context.get('session_id')
|
||||
if query == '#清除记忆':
|
||||
Session.clear_session(from_user_id)
|
||||
return '记忆已清除'
|
||||
elif query == '#清除所有':
|
||||
Session.clear_all_session()
|
||||
return '所有人记忆已清除'
|
||||
if context and context.type:
|
||||
if context.type == ContextType.TEXT:
|
||||
logger.info("[OPEN_AI] query={}".format(query))
|
||||
session_id = context["session_id"]
|
||||
reply = None
|
||||
if query == "#清除记忆":
|
||||
self.sessions.clear_session(session_id)
|
||||
reply = Reply(ReplyType.INFO, "记忆已清除")
|
||||
elif query == "#清除所有":
|
||||
self.sessions.clear_all_session()
|
||||
reply = Reply(ReplyType.INFO, "所有人记忆已清除")
|
||||
else:
|
||||
session = self.sessions.session_query(query, session_id)
|
||||
result = self.reply_text(session)
|
||||
total_tokens, completion_tokens, reply_content = (
|
||||
result["total_tokens"],
|
||||
result["completion_tokens"],
|
||||
result["content"],
|
||||
)
|
||||
logger.debug(
|
||||
"[OPEN_AI] new_query={}, session_id={}, reply_cont={}, completion_tokens={}".format(str(session), session_id, reply_content, completion_tokens)
|
||||
)
|
||||
|
||||
new_query = Session.build_session_query(query, from_user_id)
|
||||
logger.debug("[OPEN_AI] session query={}".format(new_query))
|
||||
if total_tokens == 0:
|
||||
reply = Reply(ReplyType.ERROR, reply_content)
|
||||
else:
|
||||
self.sessions.session_reply(reply_content, session_id, total_tokens)
|
||||
reply = Reply(ReplyType.TEXT, reply_content)
|
||||
return reply
|
||||
elif context.type == ContextType.IMAGE_CREATE:
|
||||
ok, retstring = self.create_img(query, 0)
|
||||
reply = None
|
||||
if ok:
|
||||
reply = Reply(ReplyType.IMAGE_URL, retstring)
|
||||
else:
|
||||
reply = Reply(ReplyType.ERROR, retstring)
|
||||
return reply
|
||||
|
||||
reply_content = self.reply_text(new_query, from_user_id, 0)
|
||||
logger.debug("[OPEN_AI] new_query={}, user={}, reply_cont={}".format(new_query, from_user_id, reply_content))
|
||||
if reply_content and query:
|
||||
Session.save_session(query, reply_content, from_user_id)
|
||||
return reply_content
|
||||
|
||||
elif context.get('type', None) == 'IMAGE_CREATE':
|
||||
return self.create_img(query, 0)
|
||||
|
||||
def reply_text(self, query, user_id, retry_count=0):
|
||||
def reply_text(self, session: OpenAISession, retry_count=0):
|
||||
try:
|
||||
response = openai.Completion.create(
|
||||
model= conf().get("model") or "text-davinci-003", # 对话模型的名称
|
||||
prompt=query,
|
||||
temperature=0.9, # 值在[0,1]之间,越大表示回复越具有不确定性
|
||||
max_tokens=1200, # 回复最大的字符数
|
||||
top_p=1,
|
||||
frequency_penalty=0.0, # [-2,2]之间,该值越大则更倾向于产生不同的内容
|
||||
presence_penalty=0.0, # [-2,2]之间,该值越大则更倾向于产生不同的内容
|
||||
stop=["\n\n\n"]
|
||||
)
|
||||
res_content = response.choices[0]['text'].strip().replace('<|endoftext|>', '')
|
||||
response = openai.Completion.create(prompt=str(session), **self.args)
|
||||
res_content = response.choices[0]["text"].strip().replace("<|endoftext|>", "")
|
||||
total_tokens = response["usage"]["total_tokens"]
|
||||
completion_tokens = response["usage"]["completion_tokens"]
|
||||
logger.info("[OPEN_AI] reply={}".format(res_content))
|
||||
return res_content
|
||||
except openai.error.RateLimitError as e:
|
||||
# rate limit exception
|
||||
logger.warn(e)
|
||||
if retry_count < 1:
|
||||
time.sleep(5)
|
||||
logger.warn("[OPEN_AI] RateLimit exceed, 第{}次重试".format(retry_count+1))
|
||||
return self.reply_text(query, user_id, retry_count+1)
|
||||
else:
|
||||
return "提问太快啦,请休息一下再问我吧"
|
||||
return {
|
||||
"total_tokens": total_tokens,
|
||||
"completion_tokens": completion_tokens,
|
||||
"content": res_content,
|
||||
}
|
||||
except Exception as e:
|
||||
# unknown exception
|
||||
logger.exception(e)
|
||||
Session.clear_session(user_id)
|
||||
return "请再问我一次吧"
|
||||
|
||||
|
||||
def create_img(self, query, retry_count=0):
|
||||
try:
|
||||
logger.info("[OPEN_AI] image_query={}".format(query))
|
||||
response = openai.Image.create(
|
||||
prompt=query, #图片描述
|
||||
n=1, #每次生成图片的数量
|
||||
size="256x256" #图片大小,可选有 256x256, 512x512, 1024x1024
|
||||
)
|
||||
image_url = response['data'][0]['url']
|
||||
logger.info("[OPEN_AI] image_url={}".format(image_url))
|
||||
return image_url
|
||||
except openai.error.RateLimitError as e:
|
||||
logger.warn(e)
|
||||
if retry_count < 1:
|
||||
time.sleep(5)
|
||||
logger.warn("[OPEN_AI] ImgCreate RateLimit exceed, 第{}次重试".format(retry_count+1))
|
||||
return self.reply_text(query, retry_count+1)
|
||||
need_retry = retry_count < 2
|
||||
result = {"completion_tokens": 0, "content": "我现在有点累了,等会再来吧"}
|
||||
if isinstance(e, openai.error.RateLimitError):
|
||||
logger.warn("[OPEN_AI] RateLimitError: {}".format(e))
|
||||
result["content"] = "提问太快啦,请休息一下再问我吧"
|
||||
if need_retry:
|
||||
time.sleep(20)
|
||||
elif isinstance(e, openai.error.Timeout):
|
||||
logger.warn("[OPEN_AI] Timeout: {}".format(e))
|
||||
result["content"] = "我没有收到你的消息"
|
||||
if need_retry:
|
||||
time.sleep(5)
|
||||
elif isinstance(e, openai.error.APIConnectionError):
|
||||
logger.warn("[OPEN_AI] APIConnectionError: {}".format(e))
|
||||
need_retry = False
|
||||
result["content"] = "我连接不到你的网络"
|
||||
else:
|
||||
return "提问太快啦,请休息一下再问我吧"
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
return None
|
||||
logger.warn("[OPEN_AI] Exception: {}".format(e))
|
||||
need_retry = False
|
||||
self.sessions.clear_session(session.session_id)
|
||||
|
||||
|
||||
class Session(object):
|
||||
@staticmethod
|
||||
def build_session_query(query, user_id):
|
||||
'''
|
||||
build query with conversation history
|
||||
e.g. Q: xxx
|
||||
A: xxx
|
||||
Q: xxx
|
||||
:param query: query content
|
||||
:param user_id: from user id
|
||||
:return: query content with conversaction
|
||||
'''
|
||||
prompt = conf().get("character_desc", "")
|
||||
if prompt:
|
||||
prompt += "<|endoftext|>\n\n\n"
|
||||
session = user_session.get(user_id, None)
|
||||
if session:
|
||||
for conversation in session:
|
||||
prompt += "Q: " + conversation["question"] + "\n\n\nA: " + conversation["answer"] + "<|endoftext|>\n"
|
||||
prompt += "Q: " + query + "\nA: "
|
||||
return prompt
|
||||
else:
|
||||
return prompt + "Q: " + query + "\nA: "
|
||||
|
||||
@staticmethod
|
||||
def save_session(query, answer, user_id):
|
||||
max_tokens = conf().get("conversation_max_tokens")
|
||||
if not max_tokens:
|
||||
# default 3000
|
||||
max_tokens = 1000
|
||||
conversation = dict()
|
||||
conversation["question"] = query
|
||||
conversation["answer"] = answer
|
||||
session = user_session.get(user_id)
|
||||
logger.debug(conversation)
|
||||
logger.debug(session)
|
||||
if session:
|
||||
# append conversation
|
||||
session.append(conversation)
|
||||
else:
|
||||
# create session
|
||||
queue = list()
|
||||
queue.append(conversation)
|
||||
user_session[user_id] = queue
|
||||
|
||||
# discard exceed limit conversation
|
||||
Session.discard_exceed_conversation(user_session[user_id], max_tokens)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def discard_exceed_conversation(session, max_tokens):
|
||||
count = 0
|
||||
count_list = list()
|
||||
for i in range(len(session)-1, -1, -1):
|
||||
# count tokens of conversation list
|
||||
history_conv = session[i]
|
||||
count += len(history_conv["question"]) + len(history_conv["answer"])
|
||||
count_list.append(count)
|
||||
|
||||
for c in count_list:
|
||||
if c > max_tokens:
|
||||
# pop first conversation
|
||||
session.pop(0)
|
||||
|
||||
@staticmethod
|
||||
def clear_session(user_id):
|
||||
user_session[user_id] = []
|
||||
|
||||
@staticmethod
|
||||
def clear_all_session():
|
||||
user_session.clear()
|
||||
if need_retry:
|
||||
logger.warn("[OPEN_AI] 第{}次重试".format(retry_count + 1))
|
||||
return self.reply_text(session, retry_count + 1)
|
||||
else:
|
||||
return result
|
||||
|
||||
41
bot/openai/open_ai_image.py
Normal file
41
bot/openai/open_ai_image.py
Normal file
@@ -0,0 +1,41 @@
|
||||
import time
|
||||
|
||||
import openai
|
||||
import openai.error
|
||||
|
||||
from common.log import logger
|
||||
from common.token_bucket import TokenBucket
|
||||
from config import conf
|
||||
|
||||
|
||||
# OPENAI提供的画图接口
|
||||
class OpenAIImage(object):
|
||||
def __init__(self):
|
||||
openai.api_key = conf().get("open_ai_api_key")
|
||||
if conf().get("rate_limit_dalle"):
|
||||
self.tb4dalle = TokenBucket(conf().get("rate_limit_dalle", 50))
|
||||
|
||||
def create_img(self, query, retry_count=0):
|
||||
try:
|
||||
if conf().get("rate_limit_dalle") and not self.tb4dalle.get_token():
|
||||
return False, "请求太快了,请休息一下再问我吧"
|
||||
logger.info("[OPEN_AI] image_query={}".format(query))
|
||||
response = openai.Image.create(
|
||||
prompt=query, # 图片描述
|
||||
n=1, # 每次生成图片的数量
|
||||
size=conf().get("image_create_size", "256x256"), # 图片大小,可选有 256x256, 512x512, 1024x1024
|
||||
)
|
||||
image_url = response["data"][0]["url"]
|
||||
logger.info("[OPEN_AI] image_url={}".format(image_url))
|
||||
return True, image_url
|
||||
except openai.error.RateLimitError as e:
|
||||
logger.warn(e)
|
||||
if retry_count < 1:
|
||||
time.sleep(5)
|
||||
logger.warn("[OPEN_AI] ImgCreate RateLimit exceed, 第{}次重试".format(retry_count + 1))
|
||||
return self.create_img(query, retry_count + 1)
|
||||
else:
|
||||
return False, "提问太快啦,请休息一下再问我吧"
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
return False, str(e)
|
||||
73
bot/openai/open_ai_session.py
Normal file
73
bot/openai/open_ai_session.py
Normal file
@@ -0,0 +1,73 @@
|
||||
from bot.session_manager import Session
|
||||
from common.log import logger
|
||||
|
||||
|
||||
class OpenAISession(Session):
|
||||
def __init__(self, session_id, system_prompt=None, model="text-davinci-003"):
|
||||
super().__init__(session_id, system_prompt)
|
||||
self.model = model
|
||||
self.reset()
|
||||
|
||||
def __str__(self):
|
||||
# 构造对话模型的输入
|
||||
"""
|
||||
e.g. Q: xxx
|
||||
A: xxx
|
||||
Q: xxx
|
||||
"""
|
||||
prompt = ""
|
||||
for item in self.messages:
|
||||
if item["role"] == "system":
|
||||
prompt += item["content"] + "<|endoftext|>\n\n\n"
|
||||
elif item["role"] == "user":
|
||||
prompt += "Q: " + item["content"] + "\n"
|
||||
elif item["role"] == "assistant":
|
||||
prompt += "\n\nA: " + item["content"] + "<|endoftext|>\n"
|
||||
|
||||
if len(self.messages) > 0 and self.messages[-1]["role"] == "user":
|
||||
prompt += "A: "
|
||||
return prompt
|
||||
|
||||
def discard_exceeding(self, max_tokens, cur_tokens=None):
|
||||
precise = True
|
||||
try:
|
||||
cur_tokens = self.calc_tokens()
|
||||
except Exception as e:
|
||||
precise = False
|
||||
if cur_tokens is None:
|
||||
raise e
|
||||
logger.debug("Exception when counting tokens precisely for query: {}".format(e))
|
||||
while cur_tokens > max_tokens:
|
||||
if len(self.messages) > 1:
|
||||
self.messages.pop(0)
|
||||
elif len(self.messages) == 1 and self.messages[0]["role"] == "assistant":
|
||||
self.messages.pop(0)
|
||||
if precise:
|
||||
cur_tokens = self.calc_tokens()
|
||||
else:
|
||||
cur_tokens = len(str(self))
|
||||
break
|
||||
elif len(self.messages) == 1 and self.messages[0]["role"] == "user":
|
||||
logger.warn("user question exceed max_tokens. total_tokens={}".format(cur_tokens))
|
||||
break
|
||||
else:
|
||||
logger.debug("max_tokens={}, total_tokens={}, len(conversation)={}".format(max_tokens, cur_tokens, len(self.messages)))
|
||||
break
|
||||
if precise:
|
||||
cur_tokens = self.calc_tokens()
|
||||
else:
|
||||
cur_tokens = len(str(self))
|
||||
return cur_tokens
|
||||
|
||||
def calc_tokens(self):
|
||||
return num_tokens_from_string(str(self), self.model)
|
||||
|
||||
|
||||
# refer to https://github.com/openai/openai-cookbook/blob/main/examples/How_to_count_tokens_with_tiktoken.ipynb
|
||||
def num_tokens_from_string(string: str, model: str) -> int:
|
||||
"""Returns the number of tokens in a text string."""
|
||||
import tiktoken
|
||||
|
||||
encoding = tiktoken.encoding_for_model(model)
|
||||
num_tokens = len(encoding.encode(string, disallowed_special=()))
|
||||
return num_tokens
|
||||
91
bot/session_manager.py
Normal file
91
bot/session_manager.py
Normal file
@@ -0,0 +1,91 @@
|
||||
from common.expired_dict import ExpiredDict
|
||||
from common.log import logger
|
||||
from config import conf
|
||||
|
||||
|
||||
class Session(object):
|
||||
def __init__(self, session_id, system_prompt=None):
|
||||
self.session_id = session_id
|
||||
self.messages = []
|
||||
if system_prompt is None:
|
||||
self.system_prompt = conf().get("character_desc", "")
|
||||
else:
|
||||
self.system_prompt = system_prompt
|
||||
|
||||
# 重置会话
|
||||
def reset(self):
|
||||
system_item = {"role": "system", "content": self.system_prompt}
|
||||
self.messages = [system_item]
|
||||
|
||||
def set_system_prompt(self, system_prompt):
|
||||
self.system_prompt = system_prompt
|
||||
self.reset()
|
||||
|
||||
def add_query(self, query):
|
||||
user_item = {"role": "user", "content": query}
|
||||
self.messages.append(user_item)
|
||||
|
||||
def add_reply(self, reply):
|
||||
assistant_item = {"role": "assistant", "content": reply}
|
||||
self.messages.append(assistant_item)
|
||||
|
||||
def discard_exceeding(self, max_tokens=None, cur_tokens=None):
|
||||
raise NotImplementedError
|
||||
|
||||
def calc_tokens(self):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class SessionManager(object):
|
||||
def __init__(self, sessioncls, **session_args):
|
||||
if conf().get("expires_in_seconds"):
|
||||
sessions = ExpiredDict(conf().get("expires_in_seconds"))
|
||||
else:
|
||||
sessions = dict()
|
||||
self.sessions = sessions
|
||||
self.sessioncls = sessioncls
|
||||
self.session_args = session_args
|
||||
|
||||
def build_session(self, session_id, system_prompt=None):
|
||||
"""
|
||||
如果session_id不在sessions中,创建一个新的session并添加到sessions中
|
||||
如果system_prompt不会空,会更新session的system_prompt并重置session
|
||||
"""
|
||||
if session_id is None:
|
||||
return self.sessioncls(session_id, system_prompt, **self.session_args)
|
||||
|
||||
if session_id not in self.sessions:
|
||||
self.sessions[session_id] = self.sessioncls(session_id, system_prompt, **self.session_args)
|
||||
elif system_prompt is not None: # 如果有新的system_prompt,更新并重置session
|
||||
self.sessions[session_id].set_system_prompt(system_prompt)
|
||||
session = self.sessions[session_id]
|
||||
return session
|
||||
|
||||
def session_query(self, query, session_id):
|
||||
session = self.build_session(session_id)
|
||||
session.add_query(query)
|
||||
try:
|
||||
max_tokens = conf().get("conversation_max_tokens", 1000)
|
||||
total_tokens = session.discard_exceeding(max_tokens, None)
|
||||
logger.debug("prompt tokens used={}".format(total_tokens))
|
||||
except Exception as e:
|
||||
logger.debug("Exception when counting tokens precisely for prompt: {}".format(str(e)))
|
||||
return session
|
||||
|
||||
def session_reply(self, reply, session_id, total_tokens=None):
|
||||
session = self.build_session(session_id)
|
||||
session.add_reply(reply)
|
||||
try:
|
||||
max_tokens = conf().get("conversation_max_tokens", 1000)
|
||||
tokens_cnt = session.discard_exceeding(max_tokens, total_tokens)
|
||||
logger.debug("raw total_tokens={}, savesession tokens={}".format(total_tokens, tokens_cnt))
|
||||
except Exception as e:
|
||||
logger.debug("Exception when counting tokens precisely for session: {}".format(str(e)))
|
||||
return session
|
||||
|
||||
def clear_session(self, session_id):
|
||||
if session_id in self.sessions:
|
||||
del self.sessions[session_id]
|
||||
|
||||
def clear_all_session(self):
|
||||
self.sessions.clear()
|
||||
@@ -1,24 +1,47 @@
|
||||
from bot import bot_factory
|
||||
from voice import voice_factory
|
||||
from config import conf
|
||||
from bridge.context import Context
|
||||
from bridge.reply import Reply
|
||||
from common import const
|
||||
from common.log import logger
|
||||
from common.singleton import singleton
|
||||
from config import conf
|
||||
from voice import voice_factory
|
||||
|
||||
|
||||
@singleton
|
||||
class Bridge(object):
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def fetch_reply_content(self, query, context):
|
||||
bot_type = const.CHATGPT
|
||||
self.btype = {
|
||||
"chat": const.CHATGPT,
|
||||
"voice_to_text": conf().get("voice_to_text", "openai"),
|
||||
"text_to_voice": conf().get("text_to_voice", "google"),
|
||||
}
|
||||
model_type = conf().get("model")
|
||||
if model_type in ["gpt-3.5-turbo", "gpt-4", "gpt-4-32k"]:
|
||||
bot_type = const.CHATGPT
|
||||
elif model_type in ["text-davinci-003"]:
|
||||
bot_type = const.OPEN_AI
|
||||
return bot_factory.create_bot(bot_type).reply(query, context)
|
||||
if model_type in ["text-davinci-003"]:
|
||||
self.btype["chat"] = const.OPEN_AI
|
||||
if conf().get("use_azure_chatgpt", False):
|
||||
self.btype["chat"] = const.CHATGPTONAZURE
|
||||
self.bots = {}
|
||||
|
||||
def fetch_voice_to_text(self, voiceFile):
|
||||
return voice_factory.create_voice("openai").voiceToText(voiceFile)
|
||||
def get_bot(self, typename):
|
||||
if self.bots.get(typename) is None:
|
||||
logger.info("create bot {} for {}".format(self.btype[typename], typename))
|
||||
if typename == "text_to_voice":
|
||||
self.bots[typename] = voice_factory.create_voice(self.btype[typename])
|
||||
elif typename == "voice_to_text":
|
||||
self.bots[typename] = voice_factory.create_voice(self.btype[typename])
|
||||
elif typename == "chat":
|
||||
self.bots[typename] = bot_factory.create_bot(self.btype[typename])
|
||||
return self.bots[typename]
|
||||
|
||||
def fetch_text_to_voice(self, text):
|
||||
return voice_factory.create_voice("baidu").textToVoice(text)
|
||||
def get_bot_type(self, typename):
|
||||
return self.btype[typename]
|
||||
|
||||
def fetch_reply_content(self, query, context: Context) -> Reply:
|
||||
return self.get_bot("chat").reply(query, context)
|
||||
|
||||
def fetch_voice_to_text(self, voiceFile) -> Reply:
|
||||
return self.get_bot("voice_to_text").voiceToText(voiceFile)
|
||||
|
||||
def fetch_text_to_voice(self, text) -> Reply:
|
||||
return self.get_bot("text_to_voice").textToVoice(text)
|
||||
|
||||
63
bridge/context.py
Normal file
63
bridge/context.py
Normal file
@@ -0,0 +1,63 @@
|
||||
# encoding:utf-8
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ContextType(Enum):
|
||||
TEXT = 1 # 文本消息
|
||||
VOICE = 2 # 音频消息
|
||||
IMAGE = 3 # 图片消息
|
||||
IMAGE_CREATE = 10 # 创建图片命令
|
||||
JOIN_GROUP = 20 # 加入群聊
|
||||
PATPAT = 21 # 拍了拍
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class Context:
|
||||
def __init__(self, type: ContextType = None, content=None, kwargs=dict()):
|
||||
self.type = type
|
||||
self.content = content
|
||||
self.kwargs = kwargs
|
||||
|
||||
def __contains__(self, key):
|
||||
if key == "type":
|
||||
return self.type is not None
|
||||
elif key == "content":
|
||||
return self.content is not None
|
||||
else:
|
||||
return key in self.kwargs
|
||||
|
||||
def __getitem__(self, key):
|
||||
if key == "type":
|
||||
return self.type
|
||||
elif key == "content":
|
||||
return self.content
|
||||
else:
|
||||
return self.kwargs[key]
|
||||
|
||||
def get(self, key, default=None):
|
||||
try:
|
||||
return self[key]
|
||||
except KeyError:
|
||||
return default
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
if key == "type":
|
||||
self.type = value
|
||||
elif key == "content":
|
||||
self.content = value
|
||||
else:
|
||||
self.kwargs[key] = value
|
||||
|
||||
def __delitem__(self, key):
|
||||
if key == "type":
|
||||
self.type = None
|
||||
elif key == "content":
|
||||
self.content = None
|
||||
else:
|
||||
del self.kwargs[key]
|
||||
|
||||
def __str__(self):
|
||||
return "Context(type={}, content={}, kwargs={})".format(self.type, self.content, self.kwargs)
|
||||
25
bridge/reply.py
Normal file
25
bridge/reply.py
Normal file
@@ -0,0 +1,25 @@
|
||||
# encoding:utf-8
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ReplyType(Enum):
|
||||
TEXT = 1 # 文本
|
||||
VOICE = 2 # 音频文件
|
||||
IMAGE = 3 # 图片文件
|
||||
IMAGE_URL = 4 # 图片URL
|
||||
|
||||
INFO = 9
|
||||
ERROR = 10
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class Reply:
|
||||
def __init__(self, type: ReplyType = None, content=None):
|
||||
self.type = type
|
||||
self.content = content
|
||||
|
||||
def __str__(self):
|
||||
return "Reply(type={}, content={})".format(self.type, self.content)
|
||||
@@ -3,8 +3,13 @@ Message sending channel abstract class
|
||||
"""
|
||||
|
||||
from bridge.bridge import Bridge
|
||||
from bridge.context import Context
|
||||
from bridge.reply import *
|
||||
|
||||
|
||||
class Channel(object):
|
||||
NOT_SUPPORT_REPLYTYPE = [ReplyType.VOICE, ReplyType.IMAGE]
|
||||
|
||||
def startup(self):
|
||||
"""
|
||||
init channel
|
||||
@@ -18,20 +23,21 @@ class Channel(object):
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def send(self, msg, receiver):
|
||||
# 统一的发送函数,每个Channel自行实现,根据reply的type字段发送不同类型的消息
|
||||
def send(self, reply: Reply, context: Context):
|
||||
"""
|
||||
send message to user
|
||||
:param msg: message content
|
||||
:param receiver: receiver channel account
|
||||
:return:
|
||||
:return:
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def build_reply_content(self, query, context=None):
|
||||
def build_reply_content(self, query, context: Context = None) -> Reply:
|
||||
return Bridge().fetch_reply_content(query, context)
|
||||
|
||||
def build_voice_to_text(self, voice_file):
|
||||
def build_voice_to_text(self, voice_file) -> Reply:
|
||||
return Bridge().fetch_voice_to_text(voice_file)
|
||||
|
||||
def build_text_to_voice(self, text):
|
||||
|
||||
def build_text_to_voice(self, text) -> Reply:
|
||||
return Bridge().fetch_text_to_voice(text)
|
||||
|
||||
@@ -2,19 +2,31 @@
|
||||
channel factory
|
||||
"""
|
||||
|
||||
|
||||
def create_channel(channel_type):
|
||||
"""
|
||||
create a channel instance
|
||||
:param channel_type: channel type code
|
||||
:return: channel instance
|
||||
"""
|
||||
if channel_type == 'wx':
|
||||
if channel_type == "wx":
|
||||
from channel.wechat.wechat_channel import WechatChannel
|
||||
|
||||
return WechatChannel()
|
||||
elif channel_type == 'wxy':
|
||||
elif channel_type == "wxy":
|
||||
from channel.wechat.wechaty_channel import WechatyChannel
|
||||
|
||||
return WechatyChannel()
|
||||
elif channel_type == 'terminal':
|
||||
elif channel_type == "terminal":
|
||||
from channel.terminal.terminal_channel import TerminalChannel
|
||||
|
||||
return TerminalChannel()
|
||||
elif channel_type == "wechatmp":
|
||||
from channel.wechatmp.wechatmp_channel import WechatMPChannel
|
||||
|
||||
return WechatMPChannel(passive_reply=True)
|
||||
elif channel_type == "wechatmp_service":
|
||||
from channel.wechatmp.wechatmp_channel import WechatMPChannel
|
||||
|
||||
return WechatMPChannel(passive_reply=False)
|
||||
raise RuntimeError
|
||||
|
||||
358
channel/chat_channel.py
Normal file
358
channel/chat_channel.py
Normal file
@@ -0,0 +1,358 @@
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from asyncio import CancelledError
|
||||
from concurrent.futures import Future, ThreadPoolExecutor
|
||||
|
||||
from bridge.context import *
|
||||
from bridge.reply import *
|
||||
from channel.channel import Channel
|
||||
from common.dequeue import Dequeue
|
||||
from common.log import logger
|
||||
from config import conf
|
||||
from plugins import *
|
||||
|
||||
try:
|
||||
from voice.audio_convert import any_to_wav
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
|
||||
# 抽象类, 它包含了与消息通道无关的通用处理逻辑
|
||||
class ChatChannel(Channel):
|
||||
name = None # 登录的用户名
|
||||
user_id = None # 登录的用户id
|
||||
futures = {} # 记录每个session_id提交到线程池的future对象, 用于重置会话时把没执行的future取消掉,正在执行的不会被取消
|
||||
sessions = {} # 用于控制并发,每个session_id同时只能有一个context在处理
|
||||
lock = threading.Lock() # 用于控制对sessions的访问
|
||||
handler_pool = ThreadPoolExecutor(max_workers=8) # 处理消息的线程池
|
||||
|
||||
def __init__(self):
|
||||
_thread = threading.Thread(target=self.consume)
|
||||
_thread.setDaemon(True)
|
||||
_thread.start()
|
||||
|
||||
# 根据消息构造context,消息内容相关的触发项写在这里
|
||||
def _compose_context(self, ctype: ContextType, content, **kwargs):
|
||||
context = Context(ctype, content)
|
||||
context.kwargs = kwargs
|
||||
# context首次传入时,origin_ctype是None,
|
||||
# 引入的起因是:当输入语音时,会嵌套生成两个context,第一步语音转文本,第二步通过文本生成文字回复。
|
||||
# origin_ctype用于第二步文本回复时,判断是否需要匹配前缀,如果是私聊的语音,就不需要匹配前缀
|
||||
if "origin_ctype" not in context:
|
||||
context["origin_ctype"] = ctype
|
||||
# context首次传入时,receiver是None,根据类型设置receiver
|
||||
first_in = "receiver" not in context
|
||||
# 群名匹配过程,设置session_id和receiver
|
||||
if first_in: # context首次传入时,receiver是None,根据类型设置receiver
|
||||
config = conf()
|
||||
cmsg = context["msg"]
|
||||
if context.get("isgroup", False):
|
||||
group_name = cmsg.other_user_nickname
|
||||
group_id = cmsg.other_user_id
|
||||
|
||||
group_name_white_list = config.get("group_name_white_list", [])
|
||||
group_name_keyword_white_list = config.get("group_name_keyword_white_list", [])
|
||||
if any(
|
||||
[
|
||||
group_name in group_name_white_list,
|
||||
"ALL_GROUP" in group_name_white_list,
|
||||
check_contain(group_name, group_name_keyword_white_list),
|
||||
]
|
||||
):
|
||||
group_chat_in_one_session = conf().get("group_chat_in_one_session", [])
|
||||
session_id = cmsg.actual_user_id
|
||||
if any(
|
||||
[
|
||||
group_name in group_chat_in_one_session,
|
||||
"ALL_GROUP" in group_chat_in_one_session,
|
||||
]
|
||||
):
|
||||
session_id = group_id
|
||||
else:
|
||||
return None
|
||||
context["session_id"] = session_id
|
||||
context["receiver"] = group_id
|
||||
else:
|
||||
context["session_id"] = cmsg.other_user_id
|
||||
context["receiver"] = cmsg.other_user_id
|
||||
e_context = PluginManager().emit_event(EventContext(Event.ON_RECEIVE_MESSAGE, {"channel": self, "context": context}))
|
||||
context = e_context["context"]
|
||||
if e_context.is_pass() or context is None:
|
||||
return context
|
||||
if cmsg.from_user_id == self.user_id and not config.get("trigger_by_self", True):
|
||||
logger.debug("[WX]self message skipped")
|
||||
return None
|
||||
|
||||
# 消息内容匹配过程,并处理content
|
||||
if ctype == ContextType.TEXT:
|
||||
if first_in and "」\n- - - - - - -" in content: # 初次匹配 过滤引用消息
|
||||
logger.debug("[WX]reference query skipped")
|
||||
return None
|
||||
|
||||
if context.get("isgroup", False): # 群聊
|
||||
# 校验关键字
|
||||
match_prefix = check_prefix(content, conf().get("group_chat_prefix"))
|
||||
match_contain = check_contain(content, conf().get("group_chat_keyword"))
|
||||
flag = False
|
||||
if match_prefix is not None or match_contain is not None:
|
||||
flag = True
|
||||
if match_prefix:
|
||||
content = content.replace(match_prefix, "", 1).strip()
|
||||
if context["msg"].is_at:
|
||||
logger.info("[WX]receive group at")
|
||||
if not conf().get("group_at_off", False):
|
||||
flag = True
|
||||
pattern = f"@{re.escape(self.name)}(\u2005|\u0020)"
|
||||
content = re.sub(pattern, r"", content)
|
||||
|
||||
if not flag:
|
||||
if context["origin_ctype"] == ContextType.VOICE:
|
||||
logger.info("[WX]receive group voice, but checkprefix didn't match")
|
||||
return None
|
||||
else: # 单聊
|
||||
match_prefix = check_prefix(content, conf().get("single_chat_prefix", [""]))
|
||||
if match_prefix is not None: # 判断如果匹配到自定义前缀,则返回过滤掉前缀+空格后的内容
|
||||
content = content.replace(match_prefix, "", 1).strip()
|
||||
elif context["origin_ctype"] == ContextType.VOICE: # 如果源消息是私聊的语音消息,允许不匹配前缀,放宽条件
|
||||
pass
|
||||
else:
|
||||
return None
|
||||
|
||||
img_match_prefix = check_prefix(content, conf().get("image_create_prefix"))
|
||||
if img_match_prefix:
|
||||
content = content.replace(img_match_prefix, "", 1)
|
||||
context.type = ContextType.IMAGE_CREATE
|
||||
else:
|
||||
context.type = ContextType.TEXT
|
||||
context.content = content.strip()
|
||||
if "desire_rtype" not in context and conf().get("always_reply_voice") and ReplyType.VOICE not in self.NOT_SUPPORT_REPLYTYPE:
|
||||
context["desire_rtype"] = ReplyType.VOICE
|
||||
elif context.type == ContextType.VOICE:
|
||||
if "desire_rtype" not in context and conf().get("voice_reply_voice") and ReplyType.VOICE not in self.NOT_SUPPORT_REPLYTYPE:
|
||||
context["desire_rtype"] = ReplyType.VOICE
|
||||
|
||||
return context
|
||||
|
||||
def _handle(self, context: Context):
|
||||
if context is None or not context.content:
|
||||
return
|
||||
logger.debug("[WX] ready to handle context: {}".format(context))
|
||||
# reply的构建步骤
|
||||
reply = self._generate_reply(context)
|
||||
|
||||
logger.debug("[WX] ready to decorate reply: {}".format(reply))
|
||||
# reply的包装步骤
|
||||
reply = self._decorate_reply(context, reply)
|
||||
|
||||
# reply的发送步骤
|
||||
self._send_reply(context, reply)
|
||||
|
||||
def _generate_reply(self, context: Context, reply: Reply = Reply()) -> Reply:
|
||||
e_context = PluginManager().emit_event(
|
||||
EventContext(
|
||||
Event.ON_HANDLE_CONTEXT,
|
||||
{"channel": self, "context": context, "reply": reply},
|
||||
)
|
||||
)
|
||||
reply = e_context["reply"]
|
||||
if not e_context.is_pass():
|
||||
logger.debug("[WX] ready to handle context: type={}, content={}".format(context.type, context.content))
|
||||
if context.type == ContextType.TEXT or context.type == ContextType.IMAGE_CREATE: # 文字和图片消息
|
||||
reply = super().build_reply_content(context.content, context)
|
||||
elif context.type == ContextType.VOICE: # 语音消息
|
||||
cmsg = context["msg"]
|
||||
cmsg.prepare()
|
||||
file_path = context.content
|
||||
wav_path = os.path.splitext(file_path)[0] + ".wav"
|
||||
try:
|
||||
any_to_wav(file_path, wav_path)
|
||||
except Exception as e: # 转换失败,直接使用mp3,对于某些api,mp3也可以识别
|
||||
logger.warning("[WX]any to wav error, use raw path. " + str(e))
|
||||
wav_path = file_path
|
||||
# 语音识别
|
||||
reply = super().build_voice_to_text(wav_path)
|
||||
# 删除临时文件
|
||||
try:
|
||||
os.remove(file_path)
|
||||
if wav_path != file_path:
|
||||
os.remove(wav_path)
|
||||
except Exception as e:
|
||||
pass
|
||||
# logger.warning("[WX]delete temp file error: " + str(e))
|
||||
|
||||
if reply.type == ReplyType.TEXT:
|
||||
new_context = self._compose_context(ContextType.TEXT, reply.content, **context.kwargs)
|
||||
if new_context:
|
||||
reply = self._generate_reply(new_context)
|
||||
else:
|
||||
return
|
||||
elif context.type == ContextType.IMAGE: # 图片消息,当前无默认逻辑
|
||||
pass
|
||||
else:
|
||||
logger.error("[WX] unknown context type: {}".format(context.type))
|
||||
return
|
||||
return reply
|
||||
|
||||
def _decorate_reply(self, context: Context, reply: Reply) -> Reply:
|
||||
if reply and reply.type:
|
||||
e_context = PluginManager().emit_event(
|
||||
EventContext(
|
||||
Event.ON_DECORATE_REPLY,
|
||||
{"channel": self, "context": context, "reply": reply},
|
||||
)
|
||||
)
|
||||
reply = e_context["reply"]
|
||||
desire_rtype = context.get("desire_rtype")
|
||||
if not e_context.is_pass() and reply and reply.type:
|
||||
if reply.type in self.NOT_SUPPORT_REPLYTYPE:
|
||||
logger.error("[WX]reply type not support: " + str(reply.type))
|
||||
reply.type = ReplyType.ERROR
|
||||
reply.content = "不支持发送的消息类型: " + str(reply.type)
|
||||
|
||||
if reply.type == ReplyType.TEXT:
|
||||
reply_text = reply.content
|
||||
if desire_rtype == ReplyType.VOICE and ReplyType.VOICE not in self.NOT_SUPPORT_REPLYTYPE:
|
||||
reply = super().build_text_to_voice(reply.content)
|
||||
return self._decorate_reply(context, reply)
|
||||
if context.get("isgroup", False):
|
||||
reply_text = "@" + context["msg"].actual_user_nickname + " " + reply_text.strip()
|
||||
reply_text = conf().get("group_chat_reply_prefix", "") + reply_text
|
||||
else:
|
||||
reply_text = conf().get("single_chat_reply_prefix", "") + reply_text
|
||||
reply.content = reply_text
|
||||
elif reply.type == ReplyType.ERROR or reply.type == ReplyType.INFO:
|
||||
reply.content = "[" + str(reply.type) + "]\n" + reply.content
|
||||
elif reply.type == ReplyType.IMAGE_URL or reply.type == ReplyType.VOICE or reply.type == ReplyType.IMAGE:
|
||||
pass
|
||||
else:
|
||||
logger.error("[WX] unknown reply type: {}".format(reply.type))
|
||||
return
|
||||
if desire_rtype and desire_rtype != reply.type and reply.type not in [ReplyType.ERROR, ReplyType.INFO]:
|
||||
logger.warning("[WX] desire_rtype: {}, but reply type: {}".format(context.get("desire_rtype"), reply.type))
|
||||
return reply
|
||||
|
||||
def _send_reply(self, context: Context, reply: Reply):
|
||||
if reply and reply.type:
|
||||
e_context = PluginManager().emit_event(
|
||||
EventContext(
|
||||
Event.ON_SEND_REPLY,
|
||||
{"channel": self, "context": context, "reply": reply},
|
||||
)
|
||||
)
|
||||
reply = e_context["reply"]
|
||||
if not e_context.is_pass() and reply and reply.type:
|
||||
logger.debug("[WX] ready to send reply: {}, context: {}".format(reply, context))
|
||||
self._send(reply, context)
|
||||
|
||||
def _send(self, reply: Reply, context: Context, retry_cnt=0):
|
||||
try:
|
||||
self.send(reply, context)
|
||||
except Exception as e:
|
||||
logger.error("[WX] sendMsg error: {}".format(str(e)))
|
||||
if isinstance(e, NotImplementedError):
|
||||
return
|
||||
logger.exception(e)
|
||||
if retry_cnt < 2:
|
||||
time.sleep(3 + 3 * retry_cnt)
|
||||
self._send(reply, context, retry_cnt + 1)
|
||||
|
||||
def _success_callback(self, session_id, **kwargs): # 线程正常结束时的回调函数
|
||||
logger.debug("Worker return success, session_id = {}".format(session_id))
|
||||
|
||||
def _fail_callback(self, session_id, exception, **kwargs): # 线程异常结束时的回调函数
|
||||
logger.exception("Worker return exception: {}".format(exception))
|
||||
|
||||
def _thread_pool_callback(self, session_id, **kwargs):
|
||||
def func(worker: Future):
|
||||
try:
|
||||
worker_exception = worker.exception()
|
||||
if worker_exception:
|
||||
self._fail_callback(session_id, exception=worker_exception, **kwargs)
|
||||
else:
|
||||
self._success_callback(session_id, **kwargs)
|
||||
except CancelledError as e:
|
||||
logger.info("Worker cancelled, session_id = {}".format(session_id))
|
||||
except Exception as e:
|
||||
logger.exception("Worker raise exception: {}".format(e))
|
||||
with self.lock:
|
||||
self.sessions[session_id][1].release()
|
||||
|
||||
return func
|
||||
|
||||
def produce(self, context: Context):
|
||||
session_id = context["session_id"]
|
||||
with self.lock:
|
||||
if session_id not in self.sessions:
|
||||
self.sessions[session_id] = [
|
||||
Dequeue(),
|
||||
threading.BoundedSemaphore(conf().get("concurrency_in_session", 4)),
|
||||
]
|
||||
if context.type == ContextType.TEXT and context.content.startswith("#"):
|
||||
self.sessions[session_id][0].putleft(context) # 优先处理管理命令
|
||||
else:
|
||||
self.sessions[session_id][0].put(context)
|
||||
|
||||
# 消费者函数,单独线程,用于从消息队列中取出消息并处理
|
||||
def consume(self):
|
||||
while True:
|
||||
with self.lock:
|
||||
session_ids = list(self.sessions.keys())
|
||||
for session_id in session_ids:
|
||||
context_queue, semaphore = self.sessions[session_id]
|
||||
if semaphore.acquire(blocking=False): # 等线程处理完毕才能删除
|
||||
if not context_queue.empty():
|
||||
context = context_queue.get()
|
||||
logger.debug("[WX] consume context: {}".format(context))
|
||||
future: Future = self.handler_pool.submit(self._handle, context)
|
||||
future.add_done_callback(self._thread_pool_callback(session_id, context=context))
|
||||
if session_id not in self.futures:
|
||||
self.futures[session_id] = []
|
||||
self.futures[session_id].append(future)
|
||||
elif semaphore._initial_value == semaphore._value + 1: # 除了当前,没有任务再申请到信号量,说明所有任务都处理完毕
|
||||
self.futures[session_id] = [t for t in self.futures[session_id] if not t.done()]
|
||||
assert len(self.futures[session_id]) == 0, "thread pool error"
|
||||
del self.sessions[session_id]
|
||||
else:
|
||||
semaphore.release()
|
||||
time.sleep(0.1)
|
||||
|
||||
# 取消session_id对应的所有任务,只能取消排队的消息和已提交线程池但未执行的任务
|
||||
def cancel_session(self, session_id):
|
||||
with self.lock:
|
||||
if session_id in self.sessions:
|
||||
for future in self.futures[session_id]:
|
||||
future.cancel()
|
||||
cnt = self.sessions[session_id][0].qsize()
|
||||
if cnt > 0:
|
||||
logger.info("Cancel {} messages in session {}".format(cnt, session_id))
|
||||
self.sessions[session_id][0] = Dequeue()
|
||||
|
||||
def cancel_all_session(self):
|
||||
with self.lock:
|
||||
for session_id in self.sessions:
|
||||
for future in self.futures[session_id]:
|
||||
future.cancel()
|
||||
cnt = self.sessions[session_id][0].qsize()
|
||||
if cnt > 0:
|
||||
logger.info("Cancel {} messages in session {}".format(cnt, session_id))
|
||||
self.sessions[session_id][0] = Dequeue()
|
||||
|
||||
|
||||
def check_prefix(content, prefix_list):
|
||||
if not prefix_list:
|
||||
return None
|
||||
for prefix in prefix_list:
|
||||
if content.startswith(prefix):
|
||||
return prefix
|
||||
return None
|
||||
|
||||
|
||||
def check_contain(content, keyword_list):
|
||||
if not keyword_list:
|
||||
return None
|
||||
for ky in keyword_list:
|
||||
if content.find(ky) != -1:
|
||||
return True
|
||||
return None
|
||||
85
channel/chat_message.py
Normal file
85
channel/chat_message.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
本类表示聊天消息,用于对itchat和wechaty的消息进行统一的封装。
|
||||
|
||||
填好必填项(群聊6个,非群聊8个),即可接入ChatChannel,并支持插件,参考TerminalChannel
|
||||
|
||||
ChatMessage
|
||||
msg_id: 消息id (必填)
|
||||
create_time: 消息创建时间
|
||||
|
||||
ctype: 消息类型 : ContextType (必填)
|
||||
content: 消息内容, 如果是声音/图片,这里是文件路径 (必填)
|
||||
|
||||
from_user_id: 发送者id (必填)
|
||||
from_user_nickname: 发送者昵称
|
||||
to_user_id: 接收者id (必填)
|
||||
to_user_nickname: 接收者昵称
|
||||
|
||||
other_user_id: 对方的id,如果你是发送者,那这个就是接收者id,如果你是接收者,那这个就是发送者id,如果是群消息,那这一直是群id (必填)
|
||||
other_user_nickname: 同上
|
||||
|
||||
is_group: 是否是群消息 (群聊必填)
|
||||
is_at: 是否被at
|
||||
|
||||
- (群消息时,一般会存在实际发送者,是群内某个成员的id和昵称,下列项仅在群消息时存在)
|
||||
actual_user_id: 实际发送者id (群聊必填)
|
||||
actual_user_nickname:实际发送者昵称
|
||||
|
||||
|
||||
|
||||
|
||||
_prepare_fn: 准备函数,用于准备消息的内容,比如下载图片等,
|
||||
_prepared: 是否已经调用过准备函数
|
||||
_rawmsg: 原始消息对象
|
||||
|
||||
"""
|
||||
|
||||
|
||||
class ChatMessage(object):
|
||||
msg_id = None
|
||||
create_time = None
|
||||
|
||||
ctype = None
|
||||
content = None
|
||||
|
||||
from_user_id = None
|
||||
from_user_nickname = None
|
||||
to_user_id = None
|
||||
to_user_nickname = None
|
||||
other_user_id = None
|
||||
other_user_nickname = None
|
||||
|
||||
is_group = False
|
||||
is_at = False
|
||||
actual_user_id = None
|
||||
actual_user_nickname = None
|
||||
|
||||
_prepare_fn = None
|
||||
_prepared = False
|
||||
_rawmsg = None
|
||||
|
||||
def __init__(self, _rawmsg):
|
||||
self._rawmsg = _rawmsg
|
||||
|
||||
def prepare(self):
|
||||
if self._prepare_fn and not self._prepared:
|
||||
self._prepared = True
|
||||
self._prepare_fn()
|
||||
|
||||
def __str__(self):
|
||||
return "ChatMessage: id={}, create_time={}, ctype={}, content={}, from_user_id={}, from_user_nickname={}, to_user_id={}, to_user_nickname={}, other_user_id={}, other_user_nickname={}, is_group={}, is_at={}, actual_user_id={}, actual_user_nickname={}".format(
|
||||
self.msg_id,
|
||||
self.create_time,
|
||||
self.ctype,
|
||||
self.content,
|
||||
self.from_user_id,
|
||||
self.from_user_nickname,
|
||||
self.to_user_id,
|
||||
self.to_user_nickname,
|
||||
self.other_user_id,
|
||||
self.other_user_nickname,
|
||||
self.is_group,
|
||||
self.is_at,
|
||||
self.actual_user_id,
|
||||
self.actual_user_nickname,
|
||||
)
|
||||
@@ -1,29 +1,92 @@
|
||||
from channel.channel import Channel
|
||||
import sys
|
||||
|
||||
class TerminalChannel(Channel):
|
||||
from bridge.context import *
|
||||
from bridge.reply import Reply, ReplyType
|
||||
from channel.chat_channel import ChatChannel, check_prefix
|
||||
from channel.chat_message import ChatMessage
|
||||
from common.log import logger
|
||||
from config import conf
|
||||
|
||||
|
||||
class TerminalMessage(ChatMessage):
|
||||
def __init__(
|
||||
self,
|
||||
msg_id,
|
||||
content,
|
||||
ctype=ContextType.TEXT,
|
||||
from_user_id="User",
|
||||
to_user_id="Chatgpt",
|
||||
other_user_id="Chatgpt",
|
||||
):
|
||||
self.msg_id = msg_id
|
||||
self.ctype = ctype
|
||||
self.content = content
|
||||
self.from_user_id = from_user_id
|
||||
self.to_user_id = to_user_id
|
||||
self.other_user_id = other_user_id
|
||||
|
||||
|
||||
class TerminalChannel(ChatChannel):
|
||||
NOT_SUPPORT_REPLYTYPE = [ReplyType.VOICE]
|
||||
|
||||
def send(self, reply: Reply, context: Context):
|
||||
print("\nBot:")
|
||||
if reply.type == ReplyType.IMAGE:
|
||||
from PIL import Image
|
||||
|
||||
image_storage = reply.content
|
||||
image_storage.seek(0)
|
||||
img = Image.open(image_storage)
|
||||
print("<IMAGE>")
|
||||
img.show()
|
||||
elif reply.type == ReplyType.IMAGE_URL: # 从网络下载图片
|
||||
import io
|
||||
|
||||
import requests
|
||||
from PIL import Image
|
||||
|
||||
img_url = reply.content
|
||||
pic_res = requests.get(img_url, stream=True)
|
||||
image_storage = io.BytesIO()
|
||||
for block in pic_res.iter_content(1024):
|
||||
image_storage.write(block)
|
||||
image_storage.seek(0)
|
||||
img = Image.open(image_storage)
|
||||
print(img_url)
|
||||
img.show()
|
||||
else:
|
||||
print(reply.content)
|
||||
print("\nUser:", end="")
|
||||
sys.stdout.flush()
|
||||
return
|
||||
|
||||
def startup(self):
|
||||
context = {"from_user_id": "User"}
|
||||
print("\nPlease input your question")
|
||||
context = Context()
|
||||
logger.setLevel("WARN")
|
||||
print("\nPlease input your question:\nUser:", end="")
|
||||
sys.stdout.flush()
|
||||
msg_id = 0
|
||||
while True:
|
||||
try:
|
||||
prompt = self.get_input("User:\n")
|
||||
prompt = self.get_input()
|
||||
except KeyboardInterrupt:
|
||||
print("\nExiting...")
|
||||
sys.exit()
|
||||
msg_id += 1
|
||||
trigger_prefixs = conf().get("single_chat_prefix", [""])
|
||||
if check_prefix(prompt, trigger_prefixs) is None:
|
||||
prompt = trigger_prefixs[0] + prompt # 给没触发的消息加上触发前缀
|
||||
|
||||
print("Bot:")
|
||||
sys.stdout.flush()
|
||||
for res in super().build_reply_content(prompt, context):
|
||||
print(res, end="")
|
||||
sys.stdout.flush()
|
||||
print("\n")
|
||||
context = self._compose_context(ContextType.TEXT, prompt, msg=TerminalMessage(msg_id, prompt))
|
||||
if context:
|
||||
self.produce(context)
|
||||
else:
|
||||
raise Exception("context is None")
|
||||
|
||||
|
||||
def get_input(self, prompt):
|
||||
def get_input(self):
|
||||
"""
|
||||
Multi-line input function
|
||||
"""
|
||||
print(prompt, end="")
|
||||
sys.stdout.flush()
|
||||
line = input()
|
||||
return line
|
||||
|
||||
@@ -4,227 +4,210 @@
|
||||
wechat channel
|
||||
"""
|
||||
|
||||
import itchat
|
||||
import json
|
||||
from itchat.content import *
|
||||
from channel.channel import Channel
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from common.log import logger
|
||||
from common.tmp_dir import TmpDir
|
||||
from config import conf
|
||||
import requests
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
|
||||
thread_pool = ThreadPoolExecutor(max_workers=8)
|
||||
import requests
|
||||
|
||||
from bridge.context import *
|
||||
from bridge.reply import *
|
||||
from channel.chat_channel import ChatChannel
|
||||
from channel.wechat.wechat_message import *
|
||||
from common.expired_dict import ExpiredDict
|
||||
from common.log import logger
|
||||
from common.singleton import singleton
|
||||
from common.time_check import time_checker
|
||||
from config import conf, get_appdata_dir
|
||||
from lib import itchat
|
||||
from lib.itchat.content import *
|
||||
from plugins import *
|
||||
|
||||
|
||||
@itchat.msg_register(TEXT)
|
||||
@itchat.msg_register([TEXT, VOICE, PICTURE, NOTE])
|
||||
def handler_single_msg(msg):
|
||||
WechatChannel().handle_text(msg)
|
||||
try:
|
||||
cmsg = WeChatMessage(msg, False)
|
||||
except NotImplementedError as e:
|
||||
logger.debug("[WX]single message {} skipped: {}".format(msg["MsgId"], e))
|
||||
return None
|
||||
WechatChannel().handle_single(cmsg)
|
||||
return None
|
||||
|
||||
|
||||
@itchat.msg_register(TEXT, isGroupChat=True)
|
||||
@itchat.msg_register([TEXT, VOICE, PICTURE, NOTE], isGroupChat=True)
|
||||
def handler_group_msg(msg):
|
||||
WechatChannel().handle_group(msg)
|
||||
try:
|
||||
cmsg = WeChatMessage(msg, True)
|
||||
except NotImplementedError as e:
|
||||
logger.debug("[WX]group message {} skipped: {}".format(msg["MsgId"], e))
|
||||
return None
|
||||
WechatChannel().handle_group(cmsg)
|
||||
return None
|
||||
|
||||
|
||||
@itchat.msg_register(VOICE)
|
||||
def handler_single_voice(msg):
|
||||
WechatChannel().handle_voice(msg)
|
||||
return None
|
||||
def _check(func):
|
||||
def wrapper(self, cmsg: ChatMessage):
|
||||
msgId = cmsg.msg_id
|
||||
if msgId in self.receivedMsgs:
|
||||
logger.info("Wechat message {} already received, ignore".format(msgId))
|
||||
return
|
||||
self.receivedMsgs[msgId] = cmsg
|
||||
create_time = cmsg.create_time # 消息时间戳
|
||||
if conf().get("hot_reload") == True and int(create_time) < int(time.time()) - 60: # 跳过1分钟前的历史消息
|
||||
logger.debug("[WX]history message {} skipped".format(msgId))
|
||||
return
|
||||
return func(self, cmsg)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class WechatChannel(Channel):
|
||||
# 可用的二维码生成接口
|
||||
# https://api.qrserver.com/v1/create-qr-code/?size=400×400&data=https://www.abc.com
|
||||
# https://api.isoyu.com/qr/?m=1&e=L&p=20&url=https://www.abc.com
|
||||
def qrCallback(uuid, status, qrcode):
|
||||
# logger.debug("qrCallback: {} {}".format(uuid,status))
|
||||
if status == "0":
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
img = Image.open(io.BytesIO(qrcode))
|
||||
_thread = threading.Thread(target=img.show, args=("QRCode",))
|
||||
_thread.setDaemon(True)
|
||||
_thread.start()
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
import qrcode
|
||||
|
||||
url = f"https://login.weixin.qq.com/l/{uuid}"
|
||||
|
||||
qr_api1 = "https://api.isoyu.com/qr/?m=1&e=L&p=20&url={}".format(url)
|
||||
qr_api2 = "https://api.qrserver.com/v1/create-qr-code/?size=400×400&data={}".format(url)
|
||||
qr_api3 = "https://api.pwmqr.com/qrcode/create/?url={}".format(url)
|
||||
qr_api4 = "https://my.tv.sohu.com/user/a/wvideo/getQRCode.do?text={}".format(url)
|
||||
print("You can also scan QRCode in any website below:")
|
||||
print(qr_api3)
|
||||
print(qr_api4)
|
||||
print(qr_api2)
|
||||
print(qr_api1)
|
||||
|
||||
qr = qrcode.QRCode(border=1)
|
||||
qr.add_data(url)
|
||||
qr.make(fit=True)
|
||||
qr.print_ascii(invert=True)
|
||||
|
||||
|
||||
@singleton
|
||||
class WechatChannel(ChatChannel):
|
||||
NOT_SUPPORT_REPLYTYPE = []
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
super().__init__()
|
||||
self.receivedMsgs = ExpiredDict(60 * 60 * 24)
|
||||
|
||||
def startup(self):
|
||||
itchat.instance.receivingRetryCount = 600 # 修改断线超时时间
|
||||
# login by scan QRCode
|
||||
itchat.auto_login(enableCmdQR=2, hotReload=conf().get('hot_reload', False))
|
||||
|
||||
hotReload = conf().get("hot_reload", False)
|
||||
status_path = os.path.join(get_appdata_dir(), "itchat.pkl")
|
||||
try:
|
||||
itchat.auto_login(
|
||||
enableCmdQR=2,
|
||||
hotReload=hotReload,
|
||||
statusStorageDir=status_path,
|
||||
qrCallback=qrCallback,
|
||||
)
|
||||
except Exception as e:
|
||||
if hotReload:
|
||||
logger.error("Hot reload failed, try to login without hot reload")
|
||||
itchat.logout()
|
||||
os.remove(status_path)
|
||||
itchat.auto_login(enableCmdQR=2, hotReload=hotReload, qrCallback=qrCallback)
|
||||
else:
|
||||
raise e
|
||||
self.user_id = itchat.instance.storageClass.userName
|
||||
self.name = itchat.instance.storageClass.nickName
|
||||
logger.info("Wechat login success, user_id: {}, nickname: {}".format(self.user_id, self.name))
|
||||
# start message listener
|
||||
itchat.run()
|
||||
|
||||
def handle_voice(self, msg):
|
||||
if conf().get('speech_recognition') != True :
|
||||
return
|
||||
logger.debug("[WX]receive voice msg: " + msg['FileName'])
|
||||
thread_pool.submit(self._do_handle_voice, msg)
|
||||
# handle_* 系列函数处理收到的消息后构造Context,然后传入produce函数中处理Context和发送回复
|
||||
# Context包含了消息的所有信息,包括以下属性
|
||||
# type 消息类型, 包括TEXT、VOICE、IMAGE_CREATE
|
||||
# content 消息内容,如果是TEXT类型,content就是文本内容,如果是VOICE类型,content就是语音文件名,如果是IMAGE_CREATE类型,content就是图片生成命令
|
||||
# kwargs 附加参数字典,包含以下的key:
|
||||
# session_id: 会话id
|
||||
# isgroup: 是否是群聊
|
||||
# receiver: 需要回复的对象
|
||||
# msg: ChatMessage消息对象
|
||||
# origin_ctype: 原始消息类型,语音转文字后,私聊时如果匹配前缀失败,会根据初始消息是否是语音来放宽触发规则
|
||||
# desire_rtype: 希望回复类型,默认是文本回复,设置为ReplyType.VOICE是语音回复
|
||||
|
||||
def _do_handle_voice(self, msg):
|
||||
from_user_id = msg['FromUserName']
|
||||
other_user_id = msg['User']['UserName']
|
||||
if from_user_id == other_user_id:
|
||||
file_name = TmpDir().path() + msg['FileName']
|
||||
msg.download(file_name)
|
||||
query = super().build_voice_to_text(file_name)
|
||||
if conf().get('voice_reply_voice'):
|
||||
self._do_send_voice(query, from_user_id)
|
||||
else:
|
||||
self._do_send_text(query, from_user_id)
|
||||
|
||||
def handle_text(self, msg):
|
||||
logger.debug("[WX]receive text msg: " + json.dumps(msg, ensure_ascii=False))
|
||||
content = msg['Text']
|
||||
self._handle_single_msg(msg, content)
|
||||
|
||||
def _handle_single_msg(self, msg, content):
|
||||
from_user_id = msg['FromUserName']
|
||||
to_user_id = msg['ToUserName'] # 接收人id
|
||||
other_user_id = msg['User']['UserName'] # 对手方id
|
||||
create_time = msg['CreateTime'] # 消息时间
|
||||
match_prefix = self.check_prefix(content, conf().get('single_chat_prefix'))
|
||||
if conf().get('hot_reload') == True and int(create_time) < int(time.time()) - 60: #跳过1分钟前的历史消息
|
||||
logger.debug("[WX]history message skipped")
|
||||
return
|
||||
if "」\n- - - - - - - - - - - - - - -" in content:
|
||||
logger.debug("[WX]reference query skipped")
|
||||
return
|
||||
if from_user_id == other_user_id and match_prefix is not None:
|
||||
# 好友向自己发送消息
|
||||
if match_prefix != '':
|
||||
str_list = content.split(match_prefix, 1)
|
||||
if len(str_list) == 2:
|
||||
content = str_list[1].strip()
|
||||
|
||||
img_match_prefix = self.check_prefix(content, conf().get('image_create_prefix'))
|
||||
if img_match_prefix:
|
||||
content = content.split(img_match_prefix, 1)[1].strip()
|
||||
thread_pool.submit(self._do_send_img, content, from_user_id)
|
||||
else :
|
||||
thread_pool.submit(self._do_send_text, content, from_user_id)
|
||||
elif to_user_id == other_user_id and match_prefix:
|
||||
# 自己给好友发送消息
|
||||
str_list = content.split(match_prefix, 1)
|
||||
if len(str_list) == 2:
|
||||
content = str_list[1].strip()
|
||||
img_match_prefix = self.check_prefix(content, conf().get('image_create_prefix'))
|
||||
if img_match_prefix:
|
||||
content = content.split(img_match_prefix, 1)[1].strip()
|
||||
thread_pool.submit(self._do_send_img, content, to_user_id)
|
||||
else:
|
||||
thread_pool.submit(self._do_send_text, content, to_user_id)
|
||||
|
||||
|
||||
def handle_group(self, msg):
|
||||
logger.debug("[WX]receive group msg: " + json.dumps(msg, ensure_ascii=False))
|
||||
group_name = msg['User'].get('NickName', None)
|
||||
group_id = msg['User'].get('UserName', None)
|
||||
create_time = msg['CreateTime'] # 消息时间
|
||||
if conf().get('hot_reload') == True and int(create_time) < int(time.time()) - 60: #跳过1分钟前的历史消息
|
||||
logger.debug("[WX]history group message skipped")
|
||||
return
|
||||
if not group_name:
|
||||
return ""
|
||||
origin_content = msg['Content']
|
||||
content = msg['Content']
|
||||
content_list = content.split(' ', 1)
|
||||
context_special_list = content.split('\u2005', 1)
|
||||
if len(context_special_list) == 2:
|
||||
content = context_special_list[1]
|
||||
elif len(content_list) == 2:
|
||||
content = content_list[1]
|
||||
if "」\n- - - - - - - - - - - - - - -" in content:
|
||||
logger.debug("[WX]reference query skipped")
|
||||
return ""
|
||||
config = conf()
|
||||
match_prefix = (msg['IsAt'] and not config.get("group_at_off", False)) or self.check_prefix(origin_content, config.get('group_chat_prefix')) \
|
||||
or self.check_contain(origin_content, config.get('group_chat_keyword'))
|
||||
if ('ALL_GROUP' in config.get('group_name_white_list') or group_name in config.get('group_name_white_list') or self.check_contain(group_name, config.get('group_name_keyword_white_list'))) and match_prefix:
|
||||
img_match_prefix = self.check_prefix(content, conf().get('image_create_prefix'))
|
||||
if img_match_prefix:
|
||||
content = content.split(img_match_prefix, 1)[1].strip()
|
||||
thread_pool.submit(self._do_send_img, content, group_id)
|
||||
else:
|
||||
thread_pool.submit(self._do_send_group, content, msg)
|
||||
|
||||
def send(self, msg, receiver):
|
||||
itchat.send(msg, toUserName=receiver)
|
||||
logger.info('[WX] sendMsg={}, receiver={}'.format(msg, receiver))
|
||||
|
||||
def _do_send_voice(self, query, reply_user_id):
|
||||
try:
|
||||
if not query:
|
||||
@time_checker
|
||||
@_check
|
||||
def handle_single(self, cmsg: ChatMessage):
|
||||
if cmsg.ctype == ContextType.VOICE:
|
||||
if conf().get("speech_recognition") != True:
|
||||
return
|
||||
context = dict()
|
||||
context['from_user_id'] = reply_user_id
|
||||
reply_text = super().build_reply_content(query, context)
|
||||
if reply_text:
|
||||
replyFile = super().build_text_to_voice(reply_text)
|
||||
itchat.send_file(replyFile, toUserName=reply_user_id)
|
||||
logger.info('[WX] sendFile={}, receiver={}'.format(replyFile, reply_user_id))
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
logger.debug("[WX]receive voice msg: {}".format(cmsg.content))
|
||||
elif cmsg.ctype == ContextType.IMAGE:
|
||||
logger.debug("[WX]receive image msg: {}".format(cmsg.content))
|
||||
elif cmsg.ctype == ContextType.PATPAT:
|
||||
logger.debug("[WX]receive patpat msg: {}".format(cmsg.content))
|
||||
elif cmsg.ctype == ContextType.TEXT:
|
||||
logger.debug("[WX]receive text msg: {}, cmsg={}".format(json.dumps(cmsg._rawmsg, ensure_ascii=False), cmsg))
|
||||
else:
|
||||
logger.debug("[WX]receive msg: {}, cmsg={}".format(cmsg.content, cmsg))
|
||||
context = self._compose_context(cmsg.ctype, cmsg.content, isgroup=False, msg=cmsg)
|
||||
if context:
|
||||
self.produce(context)
|
||||
|
||||
def _do_send_text(self, query, reply_user_id):
|
||||
try:
|
||||
if not query:
|
||||
@time_checker
|
||||
@_check
|
||||
def handle_group(self, cmsg: ChatMessage):
|
||||
if cmsg.ctype == ContextType.VOICE:
|
||||
if conf().get("speech_recognition") != True:
|
||||
return
|
||||
context = dict()
|
||||
context['session_id'] = reply_user_id
|
||||
reply_text = super().build_reply_content(query, context)
|
||||
if reply_text:
|
||||
self.send(conf().get("single_chat_reply_prefix") + reply_text, reply_user_id)
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
logger.debug("[WX]receive voice for group msg: {}".format(cmsg.content))
|
||||
elif cmsg.ctype == ContextType.IMAGE:
|
||||
logger.debug("[WX]receive image for group msg: {}".format(cmsg.content))
|
||||
elif cmsg.ctype in [ContextType.JOIN_GROUP, ContextType.PATPAT]:
|
||||
logger.debug("[WX]receive note msg: {}".format(cmsg.content))
|
||||
elif cmsg.ctype == ContextType.TEXT:
|
||||
# logger.debug("[WX]receive group msg: {}, cmsg={}".format(json.dumps(cmsg._rawmsg, ensure_ascii=False), cmsg))
|
||||
pass
|
||||
else:
|
||||
logger.debug("[WX]receive group msg: {}".format(cmsg.content))
|
||||
context = self._compose_context(cmsg.ctype, cmsg.content, isgroup=True, msg=cmsg)
|
||||
if context:
|
||||
self.produce(context)
|
||||
|
||||
def _do_send_img(self, query, reply_user_id):
|
||||
try:
|
||||
if not query:
|
||||
return
|
||||
context = dict()
|
||||
context['type'] = 'IMAGE_CREATE'
|
||||
img_url = super().build_reply_content(query, context)
|
||||
if not img_url:
|
||||
return
|
||||
|
||||
# 图片下载
|
||||
# 统一的发送函数,每个Channel自行实现,根据reply的type字段发送不同类型的消息
|
||||
def send(self, reply: Reply, context: Context):
|
||||
receiver = context["receiver"]
|
||||
if reply.type == ReplyType.TEXT:
|
||||
itchat.send(reply.content, toUserName=receiver)
|
||||
logger.info("[WX] sendMsg={}, receiver={}".format(reply, receiver))
|
||||
elif reply.type == ReplyType.ERROR or reply.type == ReplyType.INFO:
|
||||
itchat.send(reply.content, toUserName=receiver)
|
||||
logger.info("[WX] sendMsg={}, receiver={}".format(reply, receiver))
|
||||
elif reply.type == ReplyType.VOICE:
|
||||
itchat.send_file(reply.content, toUserName=receiver)
|
||||
logger.info("[WX] sendFile={}, receiver={}".format(reply.content, receiver))
|
||||
elif reply.type == ReplyType.IMAGE_URL: # 从网络下载图片
|
||||
img_url = reply.content
|
||||
pic_res = requests.get(img_url, stream=True)
|
||||
image_storage = io.BytesIO()
|
||||
for block in pic_res.iter_content(1024):
|
||||
image_storage.write(block)
|
||||
image_storage.seek(0)
|
||||
|
||||
# 图片发送
|
||||
itchat.send_image(image_storage, reply_user_id)
|
||||
logger.info('[WX] sendImage, receiver={}'.format(reply_user_id))
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
|
||||
def _do_send_group(self, query, msg):
|
||||
if not query:
|
||||
return
|
||||
context = dict()
|
||||
group_name = msg['User']['NickName']
|
||||
group_id = msg['User']['UserName']
|
||||
group_chat_in_one_session = conf().get('group_chat_in_one_session', [])
|
||||
if ('ALL_GROUP' in group_chat_in_one_session or \
|
||||
group_name in group_chat_in_one_session or \
|
||||
self.check_contain(group_name, group_chat_in_one_session)):
|
||||
context['session_id'] = group_id
|
||||
else:
|
||||
context['session_id'] = msg['ActualUserName']
|
||||
reply_text = super().build_reply_content(query, context)
|
||||
if reply_text:
|
||||
reply_text = '@' + msg['ActualNickName'] + ' ' + reply_text.strip()
|
||||
self.send(conf().get("group_chat_reply_prefix", "") + reply_text, group_id)
|
||||
|
||||
|
||||
def check_prefix(self, content, prefix_list):
|
||||
for prefix in prefix_list:
|
||||
if content.startswith(prefix):
|
||||
return prefix
|
||||
return None
|
||||
|
||||
|
||||
def check_contain(self, content, keyword_list):
|
||||
if not keyword_list:
|
||||
return None
|
||||
for ky in keyword_list:
|
||||
if content.find(ky) != -1:
|
||||
return True
|
||||
return None
|
||||
|
||||
itchat.send_image(image_storage, toUserName=receiver)
|
||||
logger.info("[WX] sendImage url={}, receiver={}".format(img_url, receiver))
|
||||
elif reply.type == ReplyType.IMAGE: # 从文件读取图片
|
||||
image_storage = reply.content
|
||||
image_storage.seek(0)
|
||||
itchat.send_image(image_storage, toUserName=receiver)
|
||||
logger.info("[WX] sendImage, receiver={}".format(receiver))
|
||||
|
||||
78
channel/wechat/wechat_message.py
Normal file
78
channel/wechat/wechat_message.py
Normal file
@@ -0,0 +1,78 @@
|
||||
import re
|
||||
|
||||
from bridge.context import ContextType
|
||||
from channel.chat_message import ChatMessage
|
||||
from common.log import logger
|
||||
from common.tmp_dir import TmpDir
|
||||
from lib import itchat
|
||||
from lib.itchat.content import *
|
||||
|
||||
|
||||
class WeChatMessage(ChatMessage):
|
||||
def __init__(self, itchat_msg, is_group=False):
|
||||
super().__init__(itchat_msg)
|
||||
self.msg_id = itchat_msg["MsgId"]
|
||||
self.create_time = itchat_msg["CreateTime"]
|
||||
self.is_group = is_group
|
||||
|
||||
if itchat_msg["Type"] == TEXT:
|
||||
self.ctype = ContextType.TEXT
|
||||
self.content = itchat_msg["Text"]
|
||||
elif itchat_msg["Type"] == VOICE:
|
||||
self.ctype = ContextType.VOICE
|
||||
self.content = TmpDir().path() + itchat_msg["FileName"] # content直接存临时目录路径
|
||||
self._prepare_fn = lambda: itchat_msg.download(self.content)
|
||||
elif itchat_msg["Type"] == PICTURE and itchat_msg["MsgType"] == 3:
|
||||
self.ctype = ContextType.IMAGE
|
||||
self.content = TmpDir().path() + itchat_msg["FileName"] # content直接存临时目录路径
|
||||
self._prepare_fn = lambda: itchat_msg.download(self.content)
|
||||
elif itchat_msg["Type"] == NOTE and itchat_msg["MsgType"] == 10000:
|
||||
if is_group and ("加入群聊" in itchat_msg["Content"] or "加入了群聊" in itchat_msg["Content"]):
|
||||
self.ctype = ContextType.JOIN_GROUP
|
||||
self.content = itchat_msg["Content"]
|
||||
# 这里只能得到nickname, actual_user_id还是机器人的id
|
||||
if "加入了群聊" in itchat_msg["Content"]:
|
||||
self.actual_user_nickname = re.findall(r"\"(.*?)\"", itchat_msg["Content"])[-1]
|
||||
elif "加入群聊" in itchat_msg["Content"]:
|
||||
self.actual_user_nickname = re.findall(r"\"(.*?)\"", itchat_msg["Content"])[0]
|
||||
elif "拍了拍我" in itchat_msg["Content"]:
|
||||
self.ctype = ContextType.PATPAT
|
||||
self.content = itchat_msg["Content"]
|
||||
if is_group:
|
||||
self.actual_user_nickname = re.findall(r"\"(.*?)\"", itchat_msg["Content"])[0]
|
||||
else:
|
||||
raise NotImplementedError("Unsupported note message: " + itchat_msg["Content"])
|
||||
else:
|
||||
raise NotImplementedError("Unsupported message type: Type:{} MsgType:{}".format(itchat_msg["Type"], itchat_msg["MsgType"]))
|
||||
|
||||
self.from_user_id = itchat_msg["FromUserName"]
|
||||
self.to_user_id = itchat_msg["ToUserName"]
|
||||
|
||||
user_id = itchat.instance.storageClass.userName
|
||||
nickname = itchat.instance.storageClass.nickName
|
||||
|
||||
# 虽然from_user_id和to_user_id用的少,但是为了保持一致性,还是要填充一下
|
||||
# 以下很繁琐,一句话总结:能填的都填了。
|
||||
if self.from_user_id == user_id:
|
||||
self.from_user_nickname = nickname
|
||||
if self.to_user_id == user_id:
|
||||
self.to_user_nickname = nickname
|
||||
try: # 陌生人时候, 'User'字段可能不存在
|
||||
self.other_user_id = itchat_msg["User"]["UserName"]
|
||||
self.other_user_nickname = itchat_msg["User"]["NickName"]
|
||||
if self.other_user_id == self.from_user_id:
|
||||
self.from_user_nickname = self.other_user_nickname
|
||||
if self.other_user_id == self.to_user_id:
|
||||
self.to_user_nickname = self.other_user_nickname
|
||||
except KeyError as e: # 处理偶尔没有对方信息的情况
|
||||
logger.warn("[WX]get other_user_id failed: " + str(e))
|
||||
if self.from_user_id == user_id:
|
||||
self.other_user_id = self.to_user_id
|
||||
else:
|
||||
self.other_user_id = self.from_user_id
|
||||
|
||||
if self.is_group:
|
||||
self.is_at = itchat_msg["IsAt"]
|
||||
self.actual_user_id = itchat_msg["ActualUserName"]
|
||||
if self.ctype not in [ContextType.JOIN_GROUP, ContextType.PATPAT]:
|
||||
self.actual_user_nickname = itchat_msg["ActualNickName"]
|
||||
@@ -4,290 +4,126 @@
|
||||
wechaty channel
|
||||
Python Wechaty - https://github.com/wechaty/python-wechaty
|
||||
"""
|
||||
import io
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import asyncio
|
||||
import requests
|
||||
import pysilk
|
||||
import wave
|
||||
from pydub import AudioSegment
|
||||
from typing import Optional, Union
|
||||
from wechaty_puppet import MessageType, FileBox, ScanStatus # type: ignore
|
||||
from wechaty import Wechaty, Contact
|
||||
from wechaty.user import Message, Room, MiniProgram, UrlLink
|
||||
from channel.channel import Channel
|
||||
import base64
|
||||
import os
|
||||
import time
|
||||
|
||||
from wechaty import Contact, Wechaty
|
||||
from wechaty.user import Message
|
||||
from wechaty_puppet import FileBox
|
||||
|
||||
from bridge.context import *
|
||||
from bridge.context import Context
|
||||
from bridge.reply import *
|
||||
from channel.chat_channel import ChatChannel
|
||||
from channel.wechat.wechaty_message import WechatyMessage
|
||||
from common.log import logger
|
||||
from common.tmp_dir import TmpDir
|
||||
from common.singleton import singleton
|
||||
from config import conf
|
||||
|
||||
try:
|
||||
from voice.audio_convert import any_to_sil
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
class WechatyChannel(Channel):
|
||||
|
||||
@singleton
|
||||
class WechatyChannel(ChatChannel):
|
||||
NOT_SUPPORT_REPLYTYPE = []
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
super().__init__()
|
||||
|
||||
def startup(self):
|
||||
config = conf()
|
||||
token = config.get("wechaty_puppet_service_token")
|
||||
os.environ["WECHATY_PUPPET_SERVICE_TOKEN"] = token
|
||||
asyncio.run(self.main())
|
||||
|
||||
async def main(self):
|
||||
config = conf()
|
||||
# 使用PadLocal协议 比较稳定(免费web协议 os.environ['WECHATY_PUPPET_SERVICE_ENDPOINT'] = '127.0.0.1:8080')
|
||||
token = config.get('wechaty_puppet_service_token')
|
||||
os.environ['WECHATY_PUPPET_SERVICE_TOKEN'] = token
|
||||
global bot
|
||||
bot = Wechaty()
|
||||
|
||||
bot.on('scan', self.on_scan)
|
||||
bot.on('login', self.on_login)
|
||||
bot.on('message', self.on_message)
|
||||
await bot.start()
|
||||
loop = asyncio.get_event_loop()
|
||||
# 将asyncio的loop传入处理线程
|
||||
self.handler_pool._initializer = lambda: asyncio.set_event_loop(loop)
|
||||
self.bot = Wechaty()
|
||||
self.bot.on("login", self.on_login)
|
||||
self.bot.on("message", self.on_message)
|
||||
await self.bot.start()
|
||||
|
||||
async def on_login(self, contact: Contact):
|
||||
logger.info('[WX] login user={}'.format(contact))
|
||||
self.user_id = contact.contact_id
|
||||
self.name = contact.name
|
||||
logger.info("[WX] login user={}".format(contact))
|
||||
|
||||
async def on_scan(self, status: ScanStatus, qr_code: Optional[str] = None,
|
||||
data: Optional[str] = None):
|
||||
contact = self.Contact.load(self.contact_id)
|
||||
logger.info('[WX] scan user={}, scan status={}, scan qr_code={}'.format(contact, status.name, qr_code))
|
||||
# print(f'user <{contact}> scan status: {status.name} , 'f'qr_code: {qr_code}')
|
||||
# 统一的发送函数,每个Channel自行实现,根据reply的type字段发送不同类型的消息
|
||||
def send(self, reply: Reply, context: Context):
|
||||
receiver_id = context["receiver"]
|
||||
loop = asyncio.get_event_loop()
|
||||
if context["isgroup"]:
|
||||
receiver = asyncio.run_coroutine_threadsafe(self.bot.Room.find(receiver_id), loop).result()
|
||||
else:
|
||||
receiver = asyncio.run_coroutine_threadsafe(self.bot.Contact.find(receiver_id), loop).result()
|
||||
msg = None
|
||||
if reply.type == ReplyType.TEXT:
|
||||
msg = reply.content
|
||||
asyncio.run_coroutine_threadsafe(receiver.say(msg), loop).result()
|
||||
logger.info("[WX] sendMsg={}, receiver={}".format(reply, receiver))
|
||||
elif reply.type == ReplyType.ERROR or reply.type == ReplyType.INFO:
|
||||
msg = reply.content
|
||||
asyncio.run_coroutine_threadsafe(receiver.say(msg), loop).result()
|
||||
logger.info("[WX] sendMsg={}, receiver={}".format(reply, receiver))
|
||||
elif reply.type == ReplyType.VOICE:
|
||||
voiceLength = None
|
||||
file_path = reply.content
|
||||
sil_file = os.path.splitext(file_path)[0] + ".sil"
|
||||
voiceLength = int(any_to_sil(file_path, sil_file))
|
||||
if voiceLength >= 60000:
|
||||
voiceLength = 60000
|
||||
logger.info("[WX] voice too long, length={}, set to 60s".format(voiceLength))
|
||||
# 发送语音
|
||||
t = int(time.time())
|
||||
msg = FileBox.from_file(sil_file, name=str(t) + ".sil")
|
||||
if voiceLength is not None:
|
||||
msg.metadata["voiceLength"] = voiceLength
|
||||
asyncio.run_coroutine_threadsafe(receiver.say(msg), loop).result()
|
||||
try:
|
||||
os.remove(file_path)
|
||||
if sil_file != file_path:
|
||||
os.remove(sil_file)
|
||||
except Exception as e:
|
||||
pass
|
||||
logger.info("[WX] sendVoice={}, receiver={}".format(reply.content, receiver))
|
||||
elif reply.type == ReplyType.IMAGE_URL: # 从网络下载图片
|
||||
img_url = reply.content
|
||||
t = int(time.time())
|
||||
msg = FileBox.from_url(url=img_url, name=str(t) + ".png")
|
||||
asyncio.run_coroutine_threadsafe(receiver.say(msg), loop).result()
|
||||
logger.info("[WX] sendImage url={}, receiver={}".format(img_url, receiver))
|
||||
elif reply.type == ReplyType.IMAGE: # 从文件读取图片
|
||||
image_storage = reply.content
|
||||
image_storage.seek(0)
|
||||
t = int(time.time())
|
||||
msg = FileBox.from_base64(base64.b64encode(image_storage.read()), str(t) + ".png")
|
||||
asyncio.run_coroutine_threadsafe(receiver.say(msg), loop).result()
|
||||
logger.info("[WX] sendImage, receiver={}".format(receiver))
|
||||
|
||||
async def on_message(self, msg: Message):
|
||||
"""
|
||||
listen for message event
|
||||
"""
|
||||
from_contact = msg.talker() # 获取消息的发送者
|
||||
to_contact = msg.to() # 接收人
|
||||
room = msg.room() # 获取消息来自的群聊. 如果消息不是来自群聊, 则返回None
|
||||
from_user_id = from_contact.contact_id
|
||||
to_user_id = to_contact.contact_id # 接收人id
|
||||
# other_user_id = msg['User']['UserName'] # 对手方id
|
||||
content = msg.text()
|
||||
mention_content = await msg.mention_text() # 返回过滤掉@name后的消息
|
||||
match_prefix = self.check_prefix(content, conf().get('single_chat_prefix'))
|
||||
conversation: Union[Room, Contact] = from_contact if room is None else room
|
||||
|
||||
if room is None and msg.type() == MessageType.MESSAGE_TYPE_TEXT:
|
||||
if not msg.is_self() and match_prefix is not None:
|
||||
# 好友向自己发送消息
|
||||
if match_prefix != '':
|
||||
str_list = content.split(match_prefix, 1)
|
||||
if len(str_list) == 2:
|
||||
content = str_list[1].strip()
|
||||
|
||||
img_match_prefix = self.check_prefix(content, conf().get('image_create_prefix'))
|
||||
if img_match_prefix:
|
||||
content = content.split(img_match_prefix, 1)[1].strip()
|
||||
await self._do_send_img(content, from_user_id)
|
||||
else:
|
||||
await self._do_send(content, from_user_id)
|
||||
elif msg.is_self() and match_prefix:
|
||||
# 自己给好友发送消息
|
||||
str_list = content.split(match_prefix, 1)
|
||||
if len(str_list) == 2:
|
||||
content = str_list[1].strip()
|
||||
img_match_prefix = self.check_prefix(content, conf().get('image_create_prefix'))
|
||||
if img_match_prefix:
|
||||
content = content.split(img_match_prefix, 1)[1].strip()
|
||||
await self._do_send_img(content, to_user_id)
|
||||
else:
|
||||
await self._do_send(content, to_user_id)
|
||||
elif room is None and msg.type() == MessageType.MESSAGE_TYPE_AUDIO:
|
||||
if not msg.is_self(): # 接收语音消息
|
||||
# 下载语音文件
|
||||
voice_file = await msg.to_file_box()
|
||||
silk_file = TmpDir().path() + voice_file.name
|
||||
await voice_file.to_file(silk_file)
|
||||
logger.info("[WX]receive voice file: " + silk_file)
|
||||
# 将文件转成wav格式音频
|
||||
wav_file = silk_file.replace(".slk", ".wav")
|
||||
with open(silk_file, 'rb') as f:
|
||||
silk_data = f.read()
|
||||
pcm_data = pysilk.decode(silk_data)
|
||||
|
||||
with wave.open(wav_file, 'wb') as wav_data:
|
||||
wav_data.setnchannels(1)
|
||||
wav_data.setsampwidth(2)
|
||||
wav_data.setframerate(24000)
|
||||
wav_data.writeframes(pcm_data)
|
||||
if os.path.exists(wav_file):
|
||||
converter_state = "true" # 转换wav成功
|
||||
else:
|
||||
converter_state = "false" # 转换wav失败
|
||||
logger.info("[WX]receive voice converter: " + converter_state)
|
||||
# 语音识别为文本
|
||||
query = super().build_voice_to_text(wav_file)
|
||||
# 交验关键字
|
||||
match_prefix = self.check_prefix(query, conf().get('single_chat_prefix'))
|
||||
if match_prefix is not None:
|
||||
if match_prefix != '':
|
||||
str_list = query.split(match_prefix, 1)
|
||||
if len(str_list) == 2:
|
||||
query = str_list[1].strip()
|
||||
# 返回消息
|
||||
if conf().get('voice_reply_voice'):
|
||||
await self._do_send_voice(query, from_user_id)
|
||||
else:
|
||||
await self._do_send(query, from_user_id)
|
||||
else:
|
||||
logger.info("[WX]receive voice check prefix: " + 'False')
|
||||
# 清除缓存文件
|
||||
os.remove(wav_file)
|
||||
os.remove(silk_file)
|
||||
elif room and msg.type() == MessageType.MESSAGE_TYPE_TEXT:
|
||||
# 群组&文本消息
|
||||
room_id = room.room_id
|
||||
room_name = await room.topic()
|
||||
from_user_id = from_contact.contact_id
|
||||
from_user_name = from_contact.name
|
||||
is_at = await msg.mention_self()
|
||||
content = mention_content
|
||||
config = conf()
|
||||
match_prefix = (is_at and not config.get("group_at_off", False)) \
|
||||
or self.check_prefix(content, config.get('group_chat_prefix')) \
|
||||
or self.check_contain(content, config.get('group_chat_keyword'))
|
||||
# Wechaty判断is_at为True,返回的内容是过滤掉@之后的内容;而is_at为False,则会返回完整的内容
|
||||
# 故判断如果匹配到自定义前缀,则返回过滤掉前缀+空格后的内容,用于实现类似自定义+前缀触发生成AI图片的功能
|
||||
prefixes = config.get('group_chat_prefix')
|
||||
for prefix in prefixes:
|
||||
if content.startswith(prefix):
|
||||
content = content.replace(prefix, '', 1).strip()
|
||||
break
|
||||
if ('ALL_GROUP' in config.get('group_name_white_list') or room_name in config.get(
|
||||
'group_name_white_list') or self.check_contain(room_name, config.get(
|
||||
'group_name_keyword_white_list'))) and match_prefix:
|
||||
img_match_prefix = self.check_prefix(content, conf().get('image_create_prefix'))
|
||||
if img_match_prefix:
|
||||
content = content.split(img_match_prefix, 1)[1].strip()
|
||||
await self._do_send_group_img(content, room_id)
|
||||
else:
|
||||
await self._do_send_group(content, room_id, room_name, from_user_id, from_user_name)
|
||||
|
||||
async def send(self, message: Union[str, Message, FileBox, Contact, UrlLink, MiniProgram], receiver):
|
||||
logger.info('[WX] sendMsg={}, receiver={}'.format(message, receiver))
|
||||
if receiver:
|
||||
contact = await bot.Contact.find(receiver)
|
||||
await contact.say(message)
|
||||
|
||||
async def send_group(self, message: Union[str, Message, FileBox, Contact, UrlLink, MiniProgram], receiver):
|
||||
logger.info('[WX] sendMsg={}, receiver={}'.format(message, receiver))
|
||||
if receiver:
|
||||
room = await bot.Room.find(receiver)
|
||||
await room.say(message)
|
||||
|
||||
async def _do_send(self, query, reply_user_id):
|
||||
try:
|
||||
if not query:
|
||||
return
|
||||
context = dict()
|
||||
context['session_id'] = reply_user_id
|
||||
reply_text = super().build_reply_content(query, context)
|
||||
if reply_text:
|
||||
await self.send(conf().get("single_chat_reply_prefix") + reply_text, reply_user_id)
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
|
||||
|
||||
async def _do_send_voice(self, query, reply_user_id):
|
||||
try:
|
||||
if not query:
|
||||
return
|
||||
context = dict()
|
||||
context['session_id'] = reply_user_id
|
||||
reply_text = super().build_reply_content(query, context)
|
||||
if reply_text:
|
||||
# 转换 mp3 文件为 silk 格式
|
||||
mp3_file = super().build_text_to_voice(reply_text)
|
||||
silk_file = mp3_file.replace(".mp3", ".silk")
|
||||
# Load the MP3 file
|
||||
audio = AudioSegment.from_file(mp3_file, format="mp3")
|
||||
# Convert to WAV format
|
||||
audio = audio.set_frame_rate(24000).set_channels(1)
|
||||
wav_data = audio.raw_data
|
||||
sample_width = audio.sample_width
|
||||
# Encode to SILK format
|
||||
silk_data = pysilk.encode(wav_data, 24000)
|
||||
# Save the silk file
|
||||
with open(silk_file, "wb") as f:
|
||||
f.write(silk_data)
|
||||
# 发送语音
|
||||
t = int(time.time())
|
||||
file_box = FileBox.from_file(silk_file, name=str(t) + '.silk')
|
||||
await self.send(file_box, reply_user_id)
|
||||
# 清除缓存文件
|
||||
os.remove(mp3_file)
|
||||
os.remove(silk_file)
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
|
||||
async def _do_send_img(self, query, reply_user_id):
|
||||
try:
|
||||
if not query:
|
||||
return
|
||||
context = dict()
|
||||
context['type'] = 'IMAGE_CREATE'
|
||||
img_url = super().build_reply_content(query, context)
|
||||
if not img_url:
|
||||
return
|
||||
# 图片下载
|
||||
# pic_res = requests.get(img_url, stream=True)
|
||||
# image_storage = io.BytesIO()
|
||||
# for block in pic_res.iter_content(1024):
|
||||
# image_storage.write(block)
|
||||
# image_storage.seek(0)
|
||||
|
||||
# 图片发送
|
||||
logger.info('[WX] sendImage, receiver={}'.format(reply_user_id))
|
||||
t = int(time.time())
|
||||
file_box = FileBox.from_url(url=img_url, name=str(t) + '.png')
|
||||
await self.send(file_box, reply_user_id)
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
|
||||
async def _do_send_group(self, query, group_id, group_name, group_user_id, group_user_name):
|
||||
if not query:
|
||||
cmsg = await WechatyMessage(msg)
|
||||
except NotImplementedError as e:
|
||||
logger.debug("[WX] {}".format(e))
|
||||
return
|
||||
context = dict()
|
||||
group_chat_in_one_session = conf().get('group_chat_in_one_session', [])
|
||||
if ('ALL_GROUP' in group_chat_in_one_session or \
|
||||
group_name in group_chat_in_one_session or \
|
||||
self.check_contain(group_name, group_chat_in_one_session)):
|
||||
context['session_id'] = str(group_id)
|
||||
else:
|
||||
context['session_id'] = str(group_id) + '-' + str(group_user_id)
|
||||
reply_text = super().build_reply_content(query, context)
|
||||
if reply_text:
|
||||
reply_text = '@' + group_user_name + ' ' + reply_text.strip()
|
||||
await self.send_group(conf().get("group_chat_reply_prefix", "") + reply_text, group_id)
|
||||
|
||||
async def _do_send_group_img(self, query, reply_room_id):
|
||||
try:
|
||||
if not query:
|
||||
return
|
||||
context = dict()
|
||||
context['type'] = 'IMAGE_CREATE'
|
||||
img_url = super().build_reply_content(query, context)
|
||||
if not img_url:
|
||||
return
|
||||
# 图片发送
|
||||
logger.info('[WX] sendImage, receiver={}'.format(reply_room_id))
|
||||
t = int(time.time())
|
||||
file_box = FileBox.from_url(url=img_url, name=str(t) + '.png')
|
||||
await self.send_group(file_box, reply_room_id)
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
|
||||
def check_prefix(self, content, prefix_list):
|
||||
for prefix in prefix_list:
|
||||
if content.startswith(prefix):
|
||||
return prefix
|
||||
return None
|
||||
|
||||
def check_contain(self, content, keyword_list):
|
||||
if not keyword_list:
|
||||
return None
|
||||
for ky in keyword_list:
|
||||
if content.find(ky) != -1:
|
||||
return True
|
||||
return None
|
||||
logger.exception("[WX] {}".format(e))
|
||||
return
|
||||
logger.debug("[WX] message:{}".format(cmsg))
|
||||
room = msg.room() # 获取消息来自的群聊. 如果消息不是来自群聊, 则返回None
|
||||
isgroup = room is not None
|
||||
ctype = cmsg.ctype
|
||||
context = self._compose_context(ctype, cmsg.content, isgroup=isgroup, msg=cmsg)
|
||||
if context:
|
||||
logger.info("[WX] receiveMsg={}, context={}".format(cmsg, context))
|
||||
self.produce(context)
|
||||
|
||||
89
channel/wechat/wechaty_message.py
Normal file
89
channel/wechat/wechaty_message.py
Normal file
@@ -0,0 +1,89 @@
|
||||
import asyncio
|
||||
import re
|
||||
|
||||
from wechaty import MessageType
|
||||
from wechaty.user import Message
|
||||
|
||||
from bridge.context import ContextType
|
||||
from channel.chat_message import ChatMessage
|
||||
from common.log import logger
|
||||
from common.tmp_dir import TmpDir
|
||||
|
||||
|
||||
class aobject(object):
|
||||
"""Inheriting this class allows you to define an async __init__.
|
||||
|
||||
So you can create objects by doing something like `await MyClass(params)`
|
||||
"""
|
||||
|
||||
async def __new__(cls, *a, **kw):
|
||||
instance = super().__new__(cls)
|
||||
await instance.__init__(*a, **kw)
|
||||
return instance
|
||||
|
||||
async def __init__(self):
|
||||
pass
|
||||
|
||||
|
||||
class WechatyMessage(ChatMessage, aobject):
|
||||
async def __init__(self, wechaty_msg: Message):
|
||||
super().__init__(wechaty_msg)
|
||||
|
||||
room = wechaty_msg.room()
|
||||
|
||||
self.msg_id = wechaty_msg.message_id
|
||||
self.create_time = wechaty_msg.payload.timestamp
|
||||
self.is_group = room is not None
|
||||
|
||||
if wechaty_msg.type() == MessageType.MESSAGE_TYPE_TEXT:
|
||||
self.ctype = ContextType.TEXT
|
||||
self.content = wechaty_msg.text()
|
||||
elif wechaty_msg.type() == MessageType.MESSAGE_TYPE_AUDIO:
|
||||
self.ctype = ContextType.VOICE
|
||||
voice_file = await wechaty_msg.to_file_box()
|
||||
self.content = TmpDir().path() + voice_file.name # content直接存临时目录路径
|
||||
|
||||
def func():
|
||||
loop = asyncio.get_event_loop()
|
||||
asyncio.run_coroutine_threadsafe(voice_file.to_file(self.content), loop).result()
|
||||
|
||||
self._prepare_fn = func
|
||||
|
||||
else:
|
||||
raise NotImplementedError("Unsupported message type: {}".format(wechaty_msg.type()))
|
||||
|
||||
from_contact = wechaty_msg.talker() # 获取消息的发送者
|
||||
self.from_user_id = from_contact.contact_id
|
||||
self.from_user_nickname = from_contact.name
|
||||
|
||||
# group中的from和to,wechaty跟itchat含义不一样
|
||||
# wecahty: from是消息实际发送者, to:所在群
|
||||
# itchat: 如果是你发送群消息,from和to是你自己和所在群,如果是别人发群消息,from和to是所在群和你自己
|
||||
# 但这个差别不影响逻辑,group中只使用到:1.用from来判断是否是自己发的,2.actual_user_id来判断实际发送用户
|
||||
|
||||
if self.is_group:
|
||||
self.to_user_id = room.room_id
|
||||
self.to_user_nickname = await room.topic()
|
||||
else:
|
||||
to_contact = wechaty_msg.to()
|
||||
self.to_user_id = to_contact.contact_id
|
||||
self.to_user_nickname = to_contact.name
|
||||
|
||||
if self.is_group or wechaty_msg.is_self(): # 如果是群消息,other_user设置为群,如果是私聊消息,而且自己发的,就设置成对方。
|
||||
self.other_user_id = self.to_user_id
|
||||
self.other_user_nickname = self.to_user_nickname
|
||||
else:
|
||||
self.other_user_id = self.from_user_id
|
||||
self.other_user_nickname = self.from_user_nickname
|
||||
|
||||
if self.is_group: # wechaty群聊中,实际发送用户就是from_user
|
||||
self.is_at = await wechaty_msg.mention_self()
|
||||
if not self.is_at: # 有时候复制粘贴的消息,不算做@,但是内容里面会有@xxx,这里做一下兼容
|
||||
name = wechaty_msg.wechaty.user_self().name
|
||||
pattern = f"@{re.escape(name)}(\u2005|\u0020)"
|
||||
if re.search(pattern, self.content):
|
||||
logger.debug(f"wechaty message {self.msg_id} include at")
|
||||
self.is_at = True
|
||||
|
||||
self.actual_user_id = self.from_user_id
|
||||
self.actual_user_nickname = self.from_user_nickname
|
||||
100
channel/wechatmp/README.md
Normal file
100
channel/wechatmp/README.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# 微信公众号channel
|
||||
|
||||
鉴于个人微信号在服务器上通过itchat登录有封号风险,这里新增了微信公众号channel,提供无风险的服务。
|
||||
目前支持订阅号和服务号两种类型的公众号,它们都支持文本交互,语音和图片输入。其中个人主体的微信订阅号由于无法通过微信认证,存在回复时间限制,每天的图片和声音回复次数也有限制。
|
||||
|
||||
## 使用方法(订阅号,服务号类似)
|
||||
|
||||
在开始部署前,你需要一个拥有公网IP的服务器,以提供微信服务器和我们自己服务器的连接。或者你需要进行内网穿透,否则微信服务器无法将消息发送给我们的服务器。
|
||||
|
||||
此外,需要在我们的服务器上安装python的web框架web.py和wechatpy。
|
||||
以ubuntu为例(在ubuntu 22.04上测试):
|
||||
```
|
||||
pip3 install web.py
|
||||
pip3 install wechatpy
|
||||
```
|
||||
|
||||
然后在[微信公众平台](https://mp.weixin.qq.com)注册一个自己的公众号,类型选择订阅号,主体为个人即可。
|
||||
|
||||
然后根据[接入指南](https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Access_Overview.html)的说明,在[微信公众平台](https://mp.weixin.qq.com)的“设置与开发”-“基本配置”-“服务器配置”中填写服务器地址`URL`和令牌`Token`。这里的`URL`是`example.com/wx`的形式,不可以使用IP,`Token`是你自己编的一个特定的令牌。消息加解密方式如果选择了需要加密的模式,需要在配置中填写`wechatmp_aes_key`。
|
||||
|
||||
相关的服务器验证代码已经写好,你不需要再添加任何代码。你只需要在本项目根目录的`config.json`中添加
|
||||
```
|
||||
"channel_type": "wechatmp", # 如果通过了微信认证,将"wechatmp"替换为"wechatmp_service",可极大的优化使用体验
|
||||
"wechatmp_token": "xxxx", # 微信公众平台的Token
|
||||
"wechatmp_port": 8080, # 微信公众平台的端口,需要端口转发到80或443
|
||||
"wechatmp_app_id": "xxxx", # 微信公众平台的appID
|
||||
"wechatmp_app_secret": "xxxx", # 微信公众平台的appsecret
|
||||
"wechatmp_aes_key": "", # 微信公众平台的EncodingAESKey,加密模式需要
|
||||
"single_chat_prefix": [""], # 推荐设置,任意对话都可以触发回复,不添加前缀
|
||||
"single_chat_reply_prefix": "", # 推荐设置,回复不设置前缀
|
||||
"plugin_trigger_prefix": "&", # 推荐设置,在手机微信客户端中,$%^等符号与中文连在一起时会自动显示一段较大的间隔,用户体验不好。请不要使用管理员指令前缀"#",这会造成未知问题。
|
||||
```
|
||||
然后运行`python3 app.py`启动web服务器。这里会默认监听8080端口,但是微信公众号的服务器配置只支持80/443端口,有两种方法来解决这个问题。第一个是推荐的方法,使用端口转发命令将80端口转发到8080端口:
|
||||
```
|
||||
sudo iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-port 8080
|
||||
sudo iptables-save > /etc/iptables/rules.v4
|
||||
```
|
||||
第二个方法是让python程序直接监听80端口,在配置文件中设置`"wechatmp_port": 80` ,在linux上需要使用`sudo python3 app.py`启动程序。然而这会导致一系列环境和权限问题,因此不是推荐的方法。
|
||||
|
||||
443端口同理,注意需要支持SSL,也就是https的访问,在`wechatmp_channel.py`中需要修改相应的证书路径。
|
||||
|
||||
程序启动并监听端口后,在刚才的“服务器配置”中点击`提交`即可验证你的服务器。
|
||||
随后在[微信公众平台](https://mp.weixin.qq.com)启用服务器,关闭手动填写规则的自动回复,即可实现ChatGPT的自动回复。
|
||||
|
||||
之后需要在公众号开发信息下将本机IP加入到IP白名单。
|
||||
|
||||
不然在启用后,发送语音、图片等消息可能会遇到如下报错:
|
||||
```
|
||||
'errcode': 40164, 'errmsg': 'invalid ip xx.xx.xx.xx not in whitelist rid
|
||||
```
|
||||
|
||||
|
||||
## 个人微信公众号的限制
|
||||
由于人微信公众号不能通过微信认证,所以没有客服接口,因此公众号无法主动发出消息,只能被动回复。而微信官方对被动回复有5秒的时间限制,最多重试2次,因此最多只有15秒的自动回复时间窗口。因此如果问题比较复杂或者我们的服务器比较忙,ChatGPT的回答就没办法及时回复给用户。为了解决这个问题,这里做了回答缓存,它需要你在回复超时后,再次主动发送任意文字(例如1)来尝试拿到回答缓存。为了优化使用体验,目前设置了两分钟(120秒)的timeout,用户在至多两分钟后即可得到查询到回复或者错误原因。
|
||||
|
||||
另外,由于微信官方的限制,自动回复有长度限制。因此这里将ChatGPT的回答进行了拆分,以满足限制。
|
||||
|
||||
## 私有api_key
|
||||
公共api有访问频率限制(免费账号每分钟最多3次ChatGPT的API调用),这在服务多人的时候会遇到问题。因此这里多加了一个设置私有api_key的功能。目前通过godcmd插件的命令来设置私有api_key。
|
||||
|
||||
## 语音输入
|
||||
利用微信自带的语音识别功能,提供语音输入能力。需要在公众号管理页面的“设置与开发”->“接口权限”页面开启“接收语音识别结果”。
|
||||
|
||||
## 语音回复
|
||||
请在配置文件中添加以下词条:
|
||||
```
|
||||
"voice_reply_voice": true,
|
||||
```
|
||||
这样公众号将会用语音回复语音消息,实现语音对话。
|
||||
|
||||
默认的语音合成引擎是`google`,它是免费使用的。
|
||||
|
||||
如果要选择其他的语音合成引擎,请添加以下配置项:
|
||||
```
|
||||
"text_to_voice": "pytts"
|
||||
```
|
||||
|
||||
pytts是本地的语音合成引擎。还支持baidu,azure,这些你需要自行配置相关的依赖和key。
|
||||
|
||||
如果使用pytts,在ubuntu上需要安装如下依赖:
|
||||
```
|
||||
sudo apt update
|
||||
sudo apt install espeak
|
||||
sudo apt install ffmpeg
|
||||
python3 -m pip install pyttsx3
|
||||
```
|
||||
不是很建议开启pytts语音回复,因为它是离线本地计算,算的慢会拖垮服务器,且声音不好听。
|
||||
|
||||
## 图片回复
|
||||
现在认证公众号和非认证公众号都可以实现的图片和语音回复。但是非认证公众号使用了永久素材接口,每天有1000次的调用上限(每个月有10次重置机会,程序中已设定遇到上限会自动重置),且永久素材库存也有上限。因此对于非认证公众号,我们会在回复图片或者语音消息后的10秒内从永久素材库存内删除该素材。
|
||||
|
||||
## 测试
|
||||
目前在`RoboStyle`这个公众号上进行了测试(基于[wechatmp分支](https://github.com/JS00000/chatgpt-on-wechat/tree/wechatmp)),感兴趣的可以关注并体验。开启了godcmd, Banwords, role, dungeon, finish这五个插件,其他的插件还没有详尽测试。百度的接口暂未测试。[wechatmp-stable分支](https://github.com/JS00000/chatgpt-on-wechat/tree/wechatmp-stable)是较稳定的上个版本,但也缺少最新的功能支持。
|
||||
|
||||
## TODO
|
||||
- [x] 语音输入
|
||||
- [x] 图片输入
|
||||
- [x] 使用临时素材接口提供认证公众号的图片和语音回复
|
||||
- [x] 使用永久素材接口提供未认证公众号的图片和语音回复
|
||||
- [ ] 高并发支持
|
||||
78
channel/wechatmp/active_reply.py
Normal file
78
channel/wechatmp/active_reply.py
Normal file
@@ -0,0 +1,78 @@
|
||||
import time
|
||||
|
||||
import web
|
||||
from wechatpy import parse_message
|
||||
from wechatpy.replies import create_reply
|
||||
|
||||
from bridge.context import *
|
||||
from bridge.reply import *
|
||||
from channel.wechatmp.common import *
|
||||
from channel.wechatmp.wechatmp_channel import WechatMPChannel
|
||||
from channel.wechatmp.wechatmp_message import WeChatMPMessage
|
||||
from common.log import logger
|
||||
from config import conf
|
||||
|
||||
|
||||
# This class is instantiated once per query
|
||||
class Query:
|
||||
def GET(self):
|
||||
return verify_server(web.input())
|
||||
|
||||
def POST(self):
|
||||
# Make sure to return the instance that first created, @singleton will do that.
|
||||
try:
|
||||
args = web.input()
|
||||
verify_server(args)
|
||||
channel = WechatMPChannel()
|
||||
message = web.data()
|
||||
encrypt_func = lambda x: x
|
||||
if args.get("encrypt_type") == "aes":
|
||||
logger.debug("[wechatmp] Receive encrypted post data:\n" + message.decode("utf-8"))
|
||||
if not channel.crypto:
|
||||
raise Exception("Crypto not initialized, Please set wechatmp_aes_key in config.json")
|
||||
message = channel.crypto.decrypt_message(message, args.msg_signature, args.timestamp, args.nonce)
|
||||
encrypt_func = lambda x: channel.crypto.encrypt_message(x, args.nonce, args.timestamp)
|
||||
else:
|
||||
logger.debug("[wechatmp] Receive post data:\n" + message.decode("utf-8"))
|
||||
msg = parse_message(message)
|
||||
if msg.type in ["text", "voice", "image"]:
|
||||
wechatmp_msg = WeChatMPMessage(msg, client=channel.client)
|
||||
from_user = wechatmp_msg.from_user_id
|
||||
content = wechatmp_msg.content
|
||||
message_id = wechatmp_msg.msg_id
|
||||
|
||||
logger.info(
|
||||
"[wechatmp] {}:{} Receive post query {} {}: {}".format(
|
||||
web.ctx.env.get("REMOTE_ADDR"),
|
||||
web.ctx.env.get("REMOTE_PORT"),
|
||||
from_user,
|
||||
message_id,
|
||||
content,
|
||||
)
|
||||
)
|
||||
if msg.type == "voice" and wechatmp_msg.ctype == ContextType.TEXT and conf().get("voice_reply_voice", False):
|
||||
context = channel._compose_context(wechatmp_msg.ctype, content, isgroup=False, desire_rtype=ReplyType.VOICE, msg=wechatmp_msg)
|
||||
else:
|
||||
context = channel._compose_context(wechatmp_msg.ctype, content, isgroup=False, msg=wechatmp_msg)
|
||||
if context:
|
||||
# set private openai_api_key
|
||||
# if from_user is not changed in itchat, this can be placed at chat_channel
|
||||
user_data = conf().get_user_data(from_user)
|
||||
context["openai_api_key"] = user_data.get("openai_api_key") # None or user openai_api_key
|
||||
channel.produce(context)
|
||||
# The reply will be sent by channel.send() in another thread
|
||||
return "success"
|
||||
elif msg.type == "event":
|
||||
logger.info("[wechatmp] Event {} from {}".format(msg.event, msg.source))
|
||||
if msg.event in ["subscribe", "subscribe_scan"]:
|
||||
reply_text = subscribe_msg()
|
||||
replyPost = create_reply(reply_text, msg)
|
||||
return encrypt_func(replyPost.render())
|
||||
else:
|
||||
return "success"
|
||||
else:
|
||||
logger.info("暂且不处理")
|
||||
return "success"
|
||||
except Exception as exc:
|
||||
logger.exception(exc)
|
||||
return exc
|
||||
62
channel/wechatmp/common.py
Normal file
62
channel/wechatmp/common.py
Normal file
@@ -0,0 +1,62 @@
|
||||
import textwrap
|
||||
|
||||
import web
|
||||
from wechatpy.crypto import WeChatCrypto
|
||||
from wechatpy.exceptions import InvalidSignatureException
|
||||
from wechatpy.utils import check_signature
|
||||
|
||||
from config import conf
|
||||
|
||||
MAX_UTF8_LEN = 2048
|
||||
|
||||
|
||||
class WeChatAPIException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def verify_server(data):
|
||||
try:
|
||||
signature = data.signature
|
||||
timestamp = data.timestamp
|
||||
nonce = data.nonce
|
||||
echostr = data.get("echostr", None)
|
||||
token = conf().get("wechatmp_token") # 请按照公众平台官网\基本配置中信息填写
|
||||
check_signature(token, signature, timestamp, nonce)
|
||||
return echostr
|
||||
except InvalidSignatureException:
|
||||
raise web.Forbidden("Invalid signature")
|
||||
except Exception as e:
|
||||
raise web.Forbidden(str(e))
|
||||
|
||||
|
||||
def subscribe_msg():
|
||||
trigger_prefix = conf().get("single_chat_prefix", [""])[0]
|
||||
msg = textwrap.dedent(
|
||||
f"""\
|
||||
感谢您的关注!
|
||||
这里是ChatGPT,可以自由对话。
|
||||
资源有限,回复较慢,请勿着急。
|
||||
支持语音对话。
|
||||
支持图片输入。
|
||||
支持图片输出,画字开头的消息将按要求创作图片。
|
||||
支持tool、角色扮演和文字冒险等丰富的插件。
|
||||
输入'{trigger_prefix}#帮助' 查看详细指令。"""
|
||||
)
|
||||
return msg
|
||||
|
||||
|
||||
def split_string_by_utf8_length(string, max_length, max_split=0):
|
||||
encoded = string.encode("utf-8")
|
||||
start, end = 0, 0
|
||||
result = []
|
||||
while end < len(encoded):
|
||||
if max_split > 0 and len(result) >= max_split:
|
||||
result.append(encoded[start:].decode("utf-8"))
|
||||
break
|
||||
end = min(start + max_length, len(encoded))
|
||||
# 如果当前字节不是 UTF-8 编码的开始字节,则向前查找直到找到开始字节为止
|
||||
while end < len(encoded) and (encoded[end] & 0b11000000) == 0b10000000:
|
||||
end -= 1
|
||||
result.append(encoded[start:end].decode("utf-8"))
|
||||
start = end
|
||||
return result
|
||||
212
channel/wechatmp/passive_reply.py
Normal file
212
channel/wechatmp/passive_reply.py
Normal file
@@ -0,0 +1,212 @@
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
import web
|
||||
from wechatpy import parse_message
|
||||
from wechatpy.replies import ImageReply, VoiceReply, create_reply
|
||||
|
||||
from bridge.context import *
|
||||
from bridge.reply import *
|
||||
from channel.wechatmp.common import *
|
||||
from channel.wechatmp.wechatmp_channel import WechatMPChannel
|
||||
from channel.wechatmp.wechatmp_message import WeChatMPMessage
|
||||
from common.log import logger
|
||||
from config import conf
|
||||
|
||||
|
||||
# This class is instantiated once per query
|
||||
class Query:
|
||||
def GET(self):
|
||||
return verify_server(web.input())
|
||||
|
||||
def POST(self):
|
||||
try:
|
||||
args = web.input()
|
||||
verify_server(args)
|
||||
request_time = time.time()
|
||||
channel = WechatMPChannel()
|
||||
message = web.data()
|
||||
encrypt_func = lambda x: x
|
||||
if args.get("encrypt_type") == "aes":
|
||||
logger.debug("[wechatmp] Receive encrypted post data:\n" + message.decode("utf-8"))
|
||||
if not channel.crypto:
|
||||
raise Exception("Crypto not initialized, Please set wechatmp_aes_key in config.json")
|
||||
message = channel.crypto.decrypt_message(message, args.msg_signature, args.timestamp, args.nonce)
|
||||
encrypt_func = lambda x: channel.crypto.encrypt_message(x, args.nonce, args.timestamp)
|
||||
else:
|
||||
logger.debug("[wechatmp] Receive post data:\n" + message.decode("utf-8"))
|
||||
msg = parse_message(message)
|
||||
if msg.type in ["text", "voice", "image"]:
|
||||
wechatmp_msg = WeChatMPMessage(msg, client=channel.client)
|
||||
from_user = wechatmp_msg.from_user_id
|
||||
content = wechatmp_msg.content
|
||||
message_id = wechatmp_msg.msg_id
|
||||
|
||||
supported = True
|
||||
if "【收到不支持的消息类型,暂无法显示】" in content:
|
||||
supported = False # not supported, used to refresh
|
||||
|
||||
# New request
|
||||
if (
|
||||
from_user not in channel.cache_dict
|
||||
and from_user not in channel.running
|
||||
or content.startswith("#")
|
||||
and message_id not in channel.request_cnt # insert the godcmd
|
||||
):
|
||||
# The first query begin
|
||||
if msg.type == "voice" and wechatmp_msg.ctype == ContextType.TEXT and conf().get("voice_reply_voice", False):
|
||||
context = channel._compose_context(wechatmp_msg.ctype, content, isgroup=False, desire_rtype=ReplyType.VOICE, msg=wechatmp_msg)
|
||||
else:
|
||||
context = channel._compose_context(wechatmp_msg.ctype, content, isgroup=False, msg=wechatmp_msg)
|
||||
logger.debug("[wechatmp] context: {} {} {}".format(context, wechatmp_msg, supported))
|
||||
|
||||
if supported and context:
|
||||
# set private openai_api_key
|
||||
# if from_user is not changed in itchat, this can be placed at chat_channel
|
||||
user_data = conf().get_user_data(from_user)
|
||||
context["openai_api_key"] = user_data.get("openai_api_key")
|
||||
channel.running.add(from_user)
|
||||
channel.produce(context)
|
||||
else:
|
||||
trigger_prefix = conf().get("single_chat_prefix", [""])[0]
|
||||
if trigger_prefix or not supported:
|
||||
if trigger_prefix:
|
||||
reply_text = textwrap.dedent(
|
||||
f"""\
|
||||
请输入'{trigger_prefix}'接你想说的话跟我说话。
|
||||
例如:
|
||||
{trigger_prefix}你好,很高兴见到你。"""
|
||||
)
|
||||
else:
|
||||
reply_text = textwrap.dedent(
|
||||
"""\
|
||||
你好,很高兴见到你。
|
||||
请跟我说话吧。"""
|
||||
)
|
||||
else:
|
||||
logger.error(f"[wechatmp] unknown error")
|
||||
reply_text = textwrap.dedent(
|
||||
"""\
|
||||
未知错误,请稍后再试"""
|
||||
)
|
||||
|
||||
replyPost = create_reply(reply_text, msg)
|
||||
return encrypt_func(replyPost.render())
|
||||
|
||||
# Wechat official server will request 3 times (5 seconds each), with the same message_id.
|
||||
# Because the interval is 5 seconds, here assumed that do not have multithreading problems.
|
||||
request_cnt = channel.request_cnt.get(message_id, 0) + 1
|
||||
channel.request_cnt[message_id] = request_cnt
|
||||
logger.info(
|
||||
"[wechatmp] Request {} from {} {} {}:{}\n{}".format(
|
||||
request_cnt, from_user, message_id, web.ctx.env.get("REMOTE_ADDR"), web.ctx.env.get("REMOTE_PORT"), content
|
||||
)
|
||||
)
|
||||
|
||||
task_running = True
|
||||
waiting_until = request_time + 4
|
||||
while time.time() < waiting_until:
|
||||
if from_user in channel.running:
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
task_running = False
|
||||
break
|
||||
|
||||
reply_text = ""
|
||||
if task_running:
|
||||
if request_cnt < 3:
|
||||
# waiting for timeout (the POST request will be closed by Wechat official server)
|
||||
time.sleep(2)
|
||||
# and do nothing, waiting for the next request
|
||||
return "success"
|
||||
else: # request_cnt == 3:
|
||||
# return timeout message
|
||||
reply_text = "【正在思考中,回复任意文字尝试获取回复】"
|
||||
replyPost = create_reply(reply_text, msg)
|
||||
return encrypt_func(replyPost.render())
|
||||
|
||||
# reply is ready
|
||||
channel.request_cnt.pop(message_id)
|
||||
|
||||
# no return because of bandwords or other reasons
|
||||
if from_user not in channel.cache_dict and from_user not in channel.running:
|
||||
return "success"
|
||||
|
||||
# Only one request can access to the cached data
|
||||
try:
|
||||
(reply_type, reply_content) = channel.cache_dict.pop(from_user)
|
||||
except KeyError:
|
||||
return "success"
|
||||
|
||||
if reply_type == "text":
|
||||
if len(reply_content.encode("utf8")) <= MAX_UTF8_LEN:
|
||||
reply_text = reply_content
|
||||
else:
|
||||
continue_text = "\n【未完待续,回复任意文字以继续】"
|
||||
splits = split_string_by_utf8_length(
|
||||
reply_content,
|
||||
MAX_UTF8_LEN - len(continue_text.encode("utf-8")),
|
||||
max_split=1,
|
||||
)
|
||||
reply_text = splits[0] + continue_text
|
||||
channel.cache_dict[from_user] = ("text", splits[1])
|
||||
|
||||
logger.info(
|
||||
"[wechatmp] Request {} do send to {} {}: {}\n{}".format(
|
||||
request_cnt,
|
||||
from_user,
|
||||
message_id,
|
||||
content,
|
||||
reply_text,
|
||||
)
|
||||
)
|
||||
replyPost = create_reply(reply_text, msg)
|
||||
return encrypt_func(replyPost.render())
|
||||
|
||||
elif reply_type == "voice":
|
||||
media_id = reply_content
|
||||
asyncio.run_coroutine_threadsafe(channel.delete_media(media_id), channel.delete_media_loop)
|
||||
logger.info(
|
||||
"[wechatmp] Request {} do send to {} {}: {} voice media_id {}".format(
|
||||
request_cnt,
|
||||
from_user,
|
||||
message_id,
|
||||
content,
|
||||
media_id,
|
||||
)
|
||||
)
|
||||
replyPost = VoiceReply(message=msg)
|
||||
replyPost.media_id = media_id
|
||||
return encrypt_func(replyPost.render())
|
||||
|
||||
elif reply_type == "image":
|
||||
media_id = reply_content
|
||||
asyncio.run_coroutine_threadsafe(channel.delete_media(media_id), channel.delete_media_loop)
|
||||
logger.info(
|
||||
"[wechatmp] Request {} do send to {} {}: {} image media_id {}".format(
|
||||
request_cnt,
|
||||
from_user,
|
||||
message_id,
|
||||
content,
|
||||
media_id,
|
||||
)
|
||||
)
|
||||
replyPost = ImageReply(message=msg)
|
||||
replyPost.media_id = media_id
|
||||
return encrypt_func(replyPost.render())
|
||||
|
||||
elif msg.type == "event":
|
||||
logger.info("[wechatmp] Event {} from {}".format(msg.event, msg.source))
|
||||
if msg.event in ["subscribe", "subscribe_scan"]:
|
||||
reply_text = subscribe_msg()
|
||||
replyPost = create_reply(reply_text, msg)
|
||||
return encrypt_func(replyPost.render())
|
||||
else:
|
||||
return "success"
|
||||
|
||||
else:
|
||||
logger.info("暂且不处理")
|
||||
return "success"
|
||||
except Exception as exc:
|
||||
logger.exception(exc)
|
||||
return exc
|
||||
213
channel/wechatmp/wechatmp_channel.py
Normal file
213
channel/wechatmp/wechatmp_channel.py
Normal file
@@ -0,0 +1,213 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import asyncio
|
||||
import imghdr
|
||||
import io
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
|
||||
import requests
|
||||
import web
|
||||
from wechatpy.crypto import WeChatCrypto
|
||||
from wechatpy.exceptions import WeChatClientException
|
||||
|
||||
from bridge.context import *
|
||||
from bridge.reply import *
|
||||
from channel.chat_channel import ChatChannel
|
||||
from channel.wechatmp.common import *
|
||||
from channel.wechatmp.wechatmp_client import WechatMPClient
|
||||
from common.log import logger
|
||||
from common.singleton import singleton
|
||||
from config import conf
|
||||
from voice.audio_convert import any_to_mp3
|
||||
|
||||
# If using SSL, uncomment the following lines, and modify the certificate path.
|
||||
# from cheroot.server import HTTPServer
|
||||
# from cheroot.ssl.builtin import BuiltinSSLAdapter
|
||||
# HTTPServer.ssl_adapter = BuiltinSSLAdapter(
|
||||
# certificate='/ssl/cert.pem',
|
||||
# private_key='/ssl/cert.key')
|
||||
|
||||
|
||||
@singleton
|
||||
class WechatMPChannel(ChatChannel):
|
||||
def __init__(self, passive_reply=True):
|
||||
super().__init__()
|
||||
self.passive_reply = passive_reply
|
||||
self.NOT_SUPPORT_REPLYTYPE = []
|
||||
appid = conf().get("wechatmp_app_id")
|
||||
secret = conf().get("wechatmp_app_secret")
|
||||
token = conf().get("wechatmp_token")
|
||||
aes_key = conf().get("wechatmp_aes_key")
|
||||
self.client = WechatMPClient(appid, secret)
|
||||
self.crypto = None
|
||||
if aes_key:
|
||||
self.crypto = WeChatCrypto(token, aes_key, appid)
|
||||
if self.passive_reply:
|
||||
# Cache the reply to the user's first message
|
||||
self.cache_dict = dict()
|
||||
# Record whether the current message is being processed
|
||||
self.running = set()
|
||||
# Count the request from wechat official server by message_id
|
||||
self.request_cnt = dict()
|
||||
# The permanent media need to be deleted to avoid media number limit
|
||||
self.delete_media_loop = asyncio.new_event_loop()
|
||||
t = threading.Thread(target=self.start_loop, args=(self.delete_media_loop,))
|
||||
t.setDaemon(True)
|
||||
t.start()
|
||||
|
||||
def startup(self):
|
||||
if self.passive_reply:
|
||||
urls = ("/wx", "channel.wechatmp.passive_reply.Query")
|
||||
else:
|
||||
urls = ("/wx", "channel.wechatmp.active_reply.Query")
|
||||
app = web.application(urls, globals(), autoreload=False)
|
||||
port = conf().get("wechatmp_port", 8080)
|
||||
web.httpserver.runsimple(app.wsgifunc(), ("0.0.0.0", port))
|
||||
|
||||
def start_loop(self, loop):
|
||||
asyncio.set_event_loop(loop)
|
||||
loop.run_forever()
|
||||
|
||||
async def delete_media(self, media_id):
|
||||
logger.debug("[wechatmp] permanent media {} will be deleted in 10s".format(media_id))
|
||||
await asyncio.sleep(10)
|
||||
self.client.material.delete(media_id)
|
||||
logger.info("[wechatmp] permanent media {} has been deleted".format(media_id))
|
||||
|
||||
def send(self, reply: Reply, context: Context):
|
||||
receiver = context["receiver"]
|
||||
if self.passive_reply:
|
||||
if reply.type == ReplyType.TEXT or reply.type == ReplyType.INFO or reply.type == ReplyType.ERROR:
|
||||
reply_text = reply.content
|
||||
logger.info("[wechatmp] text cached, receiver {}\n{}".format(receiver, reply_text))
|
||||
self.cache_dict[receiver] = ("text", reply_text)
|
||||
elif reply.type == ReplyType.VOICE:
|
||||
try:
|
||||
voice_file_path = reply.content
|
||||
with open(voice_file_path, "rb") as f:
|
||||
# support: <2M, <60s, mp3/wma/wav/amr
|
||||
response = self.client.material.add("voice", f)
|
||||
logger.debug("[wechatmp] upload voice response: {}".format(response))
|
||||
# 根据文件大小估计一个微信自动审核的时间,审核结束前返回将会导致语音无法播放,这个估计有待验证
|
||||
f_size = os.fstat(f.fileno()).st_size
|
||||
time.sleep(1.0 + 2 * f_size / 1024 / 1024)
|
||||
# todo check media_id
|
||||
except WeChatClientException as e:
|
||||
logger.error("[wechatmp] upload voice failed: {}".format(e))
|
||||
return
|
||||
media_id = response["media_id"]
|
||||
logger.info("[wechatmp] voice uploaded, receiver {}, media_id {}".format(receiver, media_id))
|
||||
self.cache_dict[receiver] = ("voice", media_id)
|
||||
|
||||
elif reply.type == ReplyType.IMAGE_URL: # 从网络下载图片
|
||||
img_url = reply.content
|
||||
pic_res = requests.get(img_url, stream=True)
|
||||
image_storage = io.BytesIO()
|
||||
for block in pic_res.iter_content(1024):
|
||||
image_storage.write(block)
|
||||
image_storage.seek(0)
|
||||
image_type = imghdr.what(image_storage)
|
||||
filename = receiver + "-" + str(context["msg"].msg_id) + "." + image_type
|
||||
content_type = "image/" + image_type
|
||||
try:
|
||||
response = self.client.material.add("image", (filename, image_storage, content_type))
|
||||
logger.debug("[wechatmp] upload image response: {}".format(response))
|
||||
except WeChatClientException as e:
|
||||
logger.error("[wechatmp] upload image failed: {}".format(e))
|
||||
return
|
||||
media_id = response["media_id"]
|
||||
logger.info("[wechatmp] image uploaded, receiver {}, media_id {}".format(receiver, media_id))
|
||||
self.cache_dict[receiver] = ("image", media_id)
|
||||
elif reply.type == ReplyType.IMAGE: # 从文件读取图片
|
||||
image_storage = reply.content
|
||||
image_storage.seek(0)
|
||||
image_type = imghdr.what(image_storage)
|
||||
filename = receiver + "-" + str(context["msg"].msg_id) + "." + image_type
|
||||
content_type = "image/" + image_type
|
||||
try:
|
||||
response = self.client.material.add("image", (filename, image_storage, content_type))
|
||||
logger.debug("[wechatmp] upload image response: {}".format(response))
|
||||
except WeChatClientException as e:
|
||||
logger.error("[wechatmp] upload image failed: {}".format(e))
|
||||
return
|
||||
media_id = response["media_id"]
|
||||
logger.info("[wechatmp] image uploaded, receiver {}, media_id {}".format(receiver, media_id))
|
||||
self.cache_dict[receiver] = ("image", media_id)
|
||||
else:
|
||||
if reply.type == ReplyType.TEXT or reply.type == ReplyType.INFO or reply.type == ReplyType.ERROR:
|
||||
reply_text = reply.content
|
||||
texts = split_string_by_utf8_length(reply_text, MAX_UTF8_LEN)
|
||||
if len(texts) > 1:
|
||||
logger.info("[wechatmp] text too long, split into {} parts".format(len(texts)))
|
||||
for text in texts:
|
||||
self.client.message.send_text(receiver, text)
|
||||
logger.info("[wechatmp] Do send text to {}: {}".format(receiver, reply_text))
|
||||
elif reply.type == ReplyType.VOICE:
|
||||
try:
|
||||
file_path = reply.content
|
||||
file_name = os.path.basename(file_path)
|
||||
file_type = os.path.splitext(file_name)[1]
|
||||
if file_type == ".mp3":
|
||||
file_type = "audio/mpeg"
|
||||
elif file_type == ".amr":
|
||||
file_type = "audio/amr"
|
||||
else:
|
||||
mp3_file = os.path.splitext(file_path)[0] + ".mp3"
|
||||
any_to_mp3(file_path, mp3_file)
|
||||
file_path = mp3_file
|
||||
file_name = os.path.basename(file_path)
|
||||
file_type = "audio/mpeg"
|
||||
logger.info("[wechatmp] file_name: {}, file_type: {} ".format(file_name, file_type))
|
||||
# support: <2M, <60s, AMR\MP3
|
||||
response = self.client.media.upload("voice", (file_name, open(file_path, "rb"), file_type))
|
||||
logger.debug("[wechatmp] upload voice response: {}".format(response))
|
||||
except WeChatClientException as e:
|
||||
logger.error("[wechatmp] upload voice failed: {}".format(e))
|
||||
return
|
||||
self.client.message.send_voice(receiver, response["media_id"])
|
||||
logger.info("[wechatmp] Do send voice to {}".format(receiver))
|
||||
elif reply.type == ReplyType.IMAGE_URL: # 从网络下载图片
|
||||
img_url = reply.content
|
||||
pic_res = requests.get(img_url, stream=True)
|
||||
image_storage = io.BytesIO()
|
||||
for block in pic_res.iter_content(1024):
|
||||
image_storage.write(block)
|
||||
image_storage.seek(0)
|
||||
image_type = imghdr.what(image_storage)
|
||||
filename = receiver + "-" + str(context["msg"].msg_id) + "." + image_type
|
||||
content_type = "image/" + image_type
|
||||
try:
|
||||
response = self.client.media.upload("image", (filename, image_storage, content_type))
|
||||
logger.debug("[wechatmp] upload image response: {}".format(response))
|
||||
except WeChatClientException as e:
|
||||
logger.error("[wechatmp] upload image failed: {}".format(e))
|
||||
return
|
||||
self.client.message.send_image(receiver, response["media_id"])
|
||||
logger.info("[wechatmp] Do send image to {}".format(receiver))
|
||||
elif reply.type == ReplyType.IMAGE: # 从文件读取图片
|
||||
image_storage = reply.content
|
||||
image_storage.seek(0)
|
||||
image_type = imghdr.what(image_storage)
|
||||
filename = receiver + "-" + str(context["msg"].msg_id) + "." + image_type
|
||||
content_type = "image/" + image_type
|
||||
try:
|
||||
response = self.client.media.upload("image", (filename, image_storage, content_type))
|
||||
logger.debug("[wechatmp] upload image response: {}".format(response))
|
||||
except WeChatClientException as e:
|
||||
logger.error("[wechatmp] upload image failed: {}".format(e))
|
||||
return
|
||||
self.client.message.send_image(receiver, response["media_id"])
|
||||
logger.info("[wechatmp] Do send image to {}".format(receiver))
|
||||
return
|
||||
|
||||
def _success_callback(self, session_id, context, **kwargs): # 线程异常结束时的回调函数
|
||||
logger.debug("[wechatmp] Success to generate reply, msgId={}".format(context["msg"].msg_id))
|
||||
if self.passive_reply:
|
||||
self.running.remove(session_id)
|
||||
|
||||
def _fail_callback(self, session_id, exception, context, **kwargs): # 线程异常结束时的回调函数
|
||||
logger.exception("[wechatmp] Fail to generate reply to user, msgId={}, exception={}".format(context["msg"].msg_id, exception))
|
||||
if self.passive_reply:
|
||||
assert session_id not in self.cache_dict
|
||||
self.running.remove(session_id)
|
||||
40
channel/wechatmp/wechatmp_client.py
Normal file
40
channel/wechatmp/wechatmp_client.py
Normal file
@@ -0,0 +1,40 @@
|
||||
import threading
|
||||
import time
|
||||
|
||||
from wechatpy.client import WeChatClient
|
||||
from wechatpy.exceptions import APILimitedException
|
||||
|
||||
from channel.wechatmp.common import *
|
||||
from common.log import logger
|
||||
|
||||
|
||||
class WechatMPClient(WeChatClient):
|
||||
def __init__(self, appid, secret, access_token=None, session=None, timeout=None, auto_retry=True):
|
||||
super(WechatMPClient, self).__init__(appid, secret, access_token, session, timeout, auto_retry)
|
||||
self.fetch_access_token_lock = threading.Lock()
|
||||
|
||||
def clear_quota(self):
|
||||
return self.post("clear_quota", data={"appid": self.appid})
|
||||
|
||||
def clear_quota_v2(self):
|
||||
return self.post("clear_quota/v2", params={"appid": self.appid, "appsecret": self.secret})
|
||||
|
||||
def fetch_access_token(self): # 重载父类方法,加锁避免多线程重复获取access_token
|
||||
with self.fetch_access_token_lock:
|
||||
access_token = self.session.get(self.access_token_key)
|
||||
if access_token:
|
||||
if not self.expires_at:
|
||||
return access_token
|
||||
timestamp = time.time()
|
||||
if self.expires_at - timestamp > 60:
|
||||
return access_token
|
||||
return super().fetch_access_token()
|
||||
|
||||
def _request(self, method, url_or_endpoint, **kwargs): # 重载父类方法,遇到API限流时,清除quota后重试
|
||||
try:
|
||||
return super()._request(method, url_or_endpoint, **kwargs)
|
||||
except APILimitedException as e:
|
||||
logger.error("[wechatmp] API quata has been used up. {}".format(e))
|
||||
response = self.clear_quota_v2()
|
||||
logger.debug("[wechatmp] API quata has been cleard, {}".format(response))
|
||||
return super()._request(method, url_or_endpoint, **kwargs)
|
||||
56
channel/wechatmp/wechatmp_message.py
Normal file
56
channel/wechatmp/wechatmp_message.py
Normal file
@@ -0,0 +1,56 @@
|
||||
# -*- coding: utf-8 -*-#
|
||||
|
||||
from bridge.context import ContextType
|
||||
from channel.chat_message import ChatMessage
|
||||
from common.log import logger
|
||||
from common.tmp_dir import TmpDir
|
||||
|
||||
|
||||
class WeChatMPMessage(ChatMessage):
|
||||
def __init__(self, msg, client=None):
|
||||
super().__init__(msg)
|
||||
self.msg_id = msg.id
|
||||
self.create_time = msg.time
|
||||
self.is_group = False
|
||||
|
||||
if msg.type == "text":
|
||||
self.ctype = ContextType.TEXT
|
||||
self.content = msg.content
|
||||
elif msg.type == "voice":
|
||||
if msg.recognition == None:
|
||||
self.ctype = ContextType.VOICE
|
||||
self.content = TmpDir().path() + msg.media_id + "." + msg.format # content直接存临时目录路径
|
||||
|
||||
def download_voice():
|
||||
# 如果响应状态码是200,则将响应内容写入本地文件
|
||||
response = client.media.download(msg.media_id)
|
||||
if response.status_code == 200:
|
||||
with open(self.content, "wb") as f:
|
||||
f.write(response.content)
|
||||
else:
|
||||
logger.info(f"[wechatmp] Failed to download voice file, {response.content}")
|
||||
|
||||
self._prepare_fn = download_voice
|
||||
else:
|
||||
self.ctype = ContextType.TEXT
|
||||
self.content = msg.recognition
|
||||
elif msg.type == "image":
|
||||
self.ctype = ContextType.IMAGE
|
||||
self.content = TmpDir().path() + msg.media_id + ".png" # content直接存临时目录路径
|
||||
|
||||
def download_image():
|
||||
# 如果响应状态码是200,则将响应内容写入本地文件
|
||||
response = client.media.download(msg.media_id)
|
||||
if response.status_code == 200:
|
||||
with open(self.content, "wb") as f:
|
||||
f.write(response.content)
|
||||
else:
|
||||
logger.info(f"[wechatmp] Failed to download image file, {response.content}")
|
||||
|
||||
self._prepare_fn = download_image
|
||||
else:
|
||||
raise NotImplementedError("Unsupported message type: Type:{} ".format(msg.type))
|
||||
|
||||
self.from_user_id = msg.source
|
||||
self.to_user_id = msg.target
|
||||
self.other_user_id = msg.source
|
||||
@@ -1,4 +1,5 @@
|
||||
# bot_type
|
||||
OPEN_AI = "openAI"
|
||||
CHATGPT = "chatGPT"
|
||||
BAIDU = "baidu"
|
||||
BAIDU = "baidu"
|
||||
CHATGPTONAZURE = "chatGPTOnAzure"
|
||||
|
||||
33
common/dequeue.py
Normal file
33
common/dequeue.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from queue import Full, Queue
|
||||
from time import monotonic as time
|
||||
|
||||
|
||||
# add implementation of putleft to Queue
|
||||
class Dequeue(Queue):
|
||||
def putleft(self, item, block=True, timeout=None):
|
||||
with self.not_full:
|
||||
if self.maxsize > 0:
|
||||
if not block:
|
||||
if self._qsize() >= self.maxsize:
|
||||
raise Full
|
||||
elif timeout is None:
|
||||
while self._qsize() >= self.maxsize:
|
||||
self.not_full.wait()
|
||||
elif timeout < 0:
|
||||
raise ValueError("'timeout' must be a non-negative number")
|
||||
else:
|
||||
endtime = time() + timeout
|
||||
while self._qsize() >= self.maxsize:
|
||||
remaining = endtime - time()
|
||||
if remaining <= 0.0:
|
||||
raise Full
|
||||
self.not_full.wait(remaining)
|
||||
self._putleft(item)
|
||||
self.unfinished_tasks += 1
|
||||
self.not_empty.notify()
|
||||
|
||||
def putleft_nowait(self, item):
|
||||
return self.putleft(item, block=False)
|
||||
|
||||
def _putleft(self, item):
|
||||
self.queue.appendleft(item)
|
||||
@@ -1,5 +1,6 @@
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
|
||||
class ExpiredDict(dict):
|
||||
def __init__(self, expires_in_seconds):
|
||||
super().__init__()
|
||||
@@ -16,8 +17,26 @@ class ExpiredDict(dict):
|
||||
def __setitem__(self, key, value):
|
||||
expiry_time = datetime.now() + timedelta(seconds=self.expires_in_seconds)
|
||||
super().__setitem__(key, (value, expiry_time))
|
||||
|
||||
def get(self, key, default=None):
|
||||
try:
|
||||
return self[key]
|
||||
except KeyError:
|
||||
return default
|
||||
return default
|
||||
|
||||
def __contains__(self, key):
|
||||
try:
|
||||
self[key]
|
||||
return True
|
||||
except KeyError:
|
||||
return False
|
||||
|
||||
def keys(self):
|
||||
keys = list(super().keys())
|
||||
return [key for key in keys if key in self]
|
||||
|
||||
def items(self):
|
||||
return [(key, self[key]) for key in self.keys()]
|
||||
|
||||
def __iter__(self):
|
||||
return self.keys().__iter__()
|
||||
|
||||
@@ -2,15 +2,37 @@ import logging
|
||||
import sys
|
||||
|
||||
|
||||
def _get_logger():
|
||||
log = logging.getLogger('log')
|
||||
log.setLevel(logging.INFO)
|
||||
def _reset_logger(log):
|
||||
for handler in log.handlers:
|
||||
handler.close()
|
||||
log.removeHandler(handler)
|
||||
del handler
|
||||
log.handlers.clear()
|
||||
log.propagate = False
|
||||
console_handle = logging.StreamHandler(sys.stdout)
|
||||
console_handle.setFormatter(logging.Formatter('[%(levelname)s][%(asctime)s][%(filename)s:%(lineno)d] - %(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S'))
|
||||
console_handle.setFormatter(
|
||||
logging.Formatter(
|
||||
"[%(levelname)s][%(asctime)s][%(filename)s:%(lineno)d] - %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
)
|
||||
file_handle = logging.FileHandler("run.log", encoding="utf-8")
|
||||
file_handle.setFormatter(
|
||||
logging.Formatter(
|
||||
"[%(levelname)s][%(asctime)s][%(filename)s:%(lineno)d] - %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
)
|
||||
log.addHandler(file_handle)
|
||||
log.addHandler(console_handle)
|
||||
|
||||
|
||||
def _get_logger():
|
||||
log = logging.getLogger("log")
|
||||
_reset_logger(log)
|
||||
log.setLevel(logging.INFO)
|
||||
return log
|
||||
|
||||
|
||||
# 日志句柄
|
||||
logger = _get_logger()
|
||||
logger = _get_logger()
|
||||
|
||||
36
common/package_manager.py
Normal file
36
common/package_manager.py
Normal file
@@ -0,0 +1,36 @@
|
||||
import time
|
||||
|
||||
import pip
|
||||
from pip._internal import main as pipmain
|
||||
|
||||
from common.log import _reset_logger, logger
|
||||
|
||||
|
||||
def install(package):
|
||||
pipmain(["install", package])
|
||||
|
||||
|
||||
def install_requirements(file):
|
||||
pipmain(["install", "-r", file, "--upgrade"])
|
||||
_reset_logger(logger)
|
||||
|
||||
|
||||
def check_dulwich():
|
||||
needwait = False
|
||||
for i in range(2):
|
||||
if needwait:
|
||||
time.sleep(3)
|
||||
needwait = False
|
||||
try:
|
||||
import dulwich
|
||||
|
||||
return
|
||||
except ImportError:
|
||||
try:
|
||||
install("dulwich")
|
||||
except:
|
||||
needwait = True
|
||||
try:
|
||||
import dulwich
|
||||
except ImportError:
|
||||
raise ImportError("Unable to import dulwich")
|
||||
9
common/singleton.py
Normal file
9
common/singleton.py
Normal file
@@ -0,0 +1,9 @@
|
||||
def singleton(cls):
|
||||
instances = {}
|
||||
|
||||
def get_instance(*args, **kwargs):
|
||||
if cls not in instances:
|
||||
instances[cls] = cls(*args, **kwargs)
|
||||
return instances[cls]
|
||||
|
||||
return get_instance
|
||||
65
common/sorted_dict.py
Normal file
65
common/sorted_dict.py
Normal file
@@ -0,0 +1,65 @@
|
||||
import heapq
|
||||
|
||||
|
||||
class SortedDict(dict):
|
||||
def __init__(self, sort_func=lambda k, v: k, init_dict=None, reverse=False):
|
||||
if init_dict is None:
|
||||
init_dict = []
|
||||
if isinstance(init_dict, dict):
|
||||
init_dict = init_dict.items()
|
||||
self.sort_func = sort_func
|
||||
self.sorted_keys = None
|
||||
self.reverse = reverse
|
||||
self.heap = []
|
||||
for k, v in init_dict:
|
||||
self[k] = v
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
if key in self:
|
||||
super().__setitem__(key, value)
|
||||
for i, (priority, k) in enumerate(self.heap):
|
||||
if k == key:
|
||||
self.heap[i] = (self.sort_func(key, value), key)
|
||||
heapq.heapify(self.heap)
|
||||
break
|
||||
self.sorted_keys = None
|
||||
else:
|
||||
super().__setitem__(key, value)
|
||||
heapq.heappush(self.heap, (self.sort_func(key, value), key))
|
||||
self.sorted_keys = None
|
||||
|
||||
def __delitem__(self, key):
|
||||
super().__delitem__(key)
|
||||
for i, (priority, k) in enumerate(self.heap):
|
||||
if k == key:
|
||||
del self.heap[i]
|
||||
heapq.heapify(self.heap)
|
||||
break
|
||||
self.sorted_keys = None
|
||||
|
||||
def keys(self):
|
||||
if self.sorted_keys is None:
|
||||
self.sorted_keys = [k for _, k in sorted(self.heap, reverse=self.reverse)]
|
||||
return self.sorted_keys
|
||||
|
||||
def items(self):
|
||||
if self.sorted_keys is None:
|
||||
self.sorted_keys = [k for _, k in sorted(self.heap, reverse=self.reverse)]
|
||||
sorted_items = [(k, self[k]) for k in self.sorted_keys]
|
||||
return sorted_items
|
||||
|
||||
def _update_heap(self, key):
|
||||
for i, (priority, k) in enumerate(self.heap):
|
||||
if k == key:
|
||||
new_priority = self.sort_func(key, self[key])
|
||||
if new_priority != priority:
|
||||
self.heap[i] = (new_priority, key)
|
||||
heapq.heapify(self.heap)
|
||||
self.sorted_keys = None
|
||||
break
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self.keys())
|
||||
|
||||
def __repr__(self):
|
||||
return f"{type(self).__name__}({dict(self)}, sort_func={self.sort_func.__name__}, reverse={self.reverse})"
|
||||
42
common/time_check.py
Normal file
42
common/time_check.py
Normal file
@@ -0,0 +1,42 @@
|
||||
import hashlib
|
||||
import re
|
||||
import time
|
||||
|
||||
import config
|
||||
from common.log import logger
|
||||
|
||||
|
||||
def time_checker(f):
|
||||
def _time_checker(self, *args, **kwargs):
|
||||
_config = config.conf()
|
||||
chat_time_module = _config.get("chat_time_module", False)
|
||||
if chat_time_module:
|
||||
chat_start_time = _config.get("chat_start_time", "00:00")
|
||||
chat_stopt_time = _config.get("chat_stop_time", "24:00")
|
||||
time_regex = re.compile(r"^([01]?[0-9]|2[0-4])(:)([0-5][0-9])$") # 时间匹配,包含24:00
|
||||
|
||||
starttime_format_check = time_regex.match(chat_start_time) # 检查停止时间格式
|
||||
stoptime_format_check = time_regex.match(chat_stopt_time) # 检查停止时间格式
|
||||
chat_time_check = chat_start_time < chat_stopt_time # 确定启动时间<停止时间
|
||||
|
||||
# 时间格式检查
|
||||
if not (starttime_format_check and stoptime_format_check and chat_time_check):
|
||||
logger.warn("时间格式不正确,请在config.json中修改您的CHAT_START_TIME/CHAT_STOP_TIME,否则可能会影响您正常使用,开始({})-结束({})".format(starttime_format_check, stoptime_format_check))
|
||||
if chat_start_time > "23:59":
|
||||
logger.error("启动时间可能存在问题,请修改!")
|
||||
|
||||
# 服务时间检查
|
||||
now_time = time.strftime("%H:%M", time.localtime())
|
||||
if chat_start_time <= now_time <= chat_stopt_time: # 服务时间内,正常返回回答
|
||||
f(self, *args, **kwargs)
|
||||
return None
|
||||
else:
|
||||
if args[0]["Content"] == "#更新配置": # 不在服务时间内也可以更新配置
|
||||
f(self, *args, **kwargs)
|
||||
else:
|
||||
logger.info("非服务时间内,不接受访问")
|
||||
return None
|
||||
else:
|
||||
f(self, *args, **kwargs) # 未开启时间模块则直接回答
|
||||
|
||||
return _time_checker
|
||||
@@ -1,20 +1,18 @@
|
||||
|
||||
import os
|
||||
import pathlib
|
||||
|
||||
from config import conf
|
||||
|
||||
|
||||
class TmpDir(object):
|
||||
"""A temporary directory that is deleted when the object is destroyed.
|
||||
"""
|
||||
"""A temporary directory that is deleted when the object is destroyed."""
|
||||
|
||||
tmpFilePath = pathlib.Path("./tmp/")
|
||||
|
||||
tmpFilePath = pathlib.Path('./tmp/')
|
||||
|
||||
def __init__(self):
|
||||
pathExists = os.path.exists(self.tmpFilePath)
|
||||
if not pathExists and conf().get('speech_recognition') == True:
|
||||
if not pathExists:
|
||||
os.makedirs(self.tmpFilePath)
|
||||
|
||||
def path(self):
|
||||
return str(self.tmpFilePath) + '/'
|
||||
|
||||
return str(self.tmpFilePath) + "/"
|
||||
|
||||
@@ -1,16 +1,31 @@
|
||||
{
|
||||
"open_ai_api_key": "YOUR API KEY",
|
||||
"model": "gpt-3.5-turbo",
|
||||
"proxy": "",
|
||||
"single_chat_prefix": ["bot", "@bot"],
|
||||
"single_chat_reply_prefix": "[bot] ",
|
||||
"group_chat_prefix": ["@bot"],
|
||||
"group_name_white_list": ["ChatGPT测试群", "ChatGPT测试群2"],
|
||||
"image_create_prefix": ["画", "看", "找"],
|
||||
"speech_recognition": false,
|
||||
"voice_reply_voice": false,
|
||||
"conversation_max_tokens": 1000,
|
||||
"expires_in_seconds": 3600,
|
||||
"character_desc": "你是ChatGPT, 一个由OpenAI训练的大型语言模型, 你旨在回答并解决人们的任何问题,并且可以使用多种语言与人交流。"
|
||||
}
|
||||
|
||||
{
|
||||
"open_ai_api_key": "YOUR API KEY",
|
||||
"model": "gpt-3.5-turbo",
|
||||
"proxy": "",
|
||||
"single_chat_prefix": [
|
||||
"bot",
|
||||
"@bot"
|
||||
],
|
||||
"single_chat_reply_prefix": "[bot] ",
|
||||
"group_chat_prefix": [
|
||||
"@bot"
|
||||
],
|
||||
"group_name_white_list": [
|
||||
"ChatGPT测试群",
|
||||
"ChatGPT测试群2"
|
||||
],
|
||||
"group_chat_in_one_session": [
|
||||
"ChatGPT测试群"
|
||||
],
|
||||
"image_create_prefix": [
|
||||
"画",
|
||||
"看",
|
||||
"找"
|
||||
],
|
||||
"speech_recognition": false,
|
||||
"group_speech_recognition": false,
|
||||
"voice_reply_voice": false,
|
||||
"conversation_max_tokens": 1000,
|
||||
"expires_in_seconds": 3600,
|
||||
"character_desc": "你是ChatGPT, 一个由OpenAI训练的大型语言模型, 你旨在回答并解决人们的任何问题,并且可以使用多种语言与人交流。"
|
||||
}
|
||||
|
||||
176
config.py
176
config.py
@@ -1,33 +1,199 @@
|
||||
# encoding:utf-8
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import pickle
|
||||
|
||||
from common.log import logger
|
||||
|
||||
config = {}
|
||||
# 将所有可用的配置项写在字典里, 请使用小写字母
|
||||
available_setting = {
|
||||
# openai api配置
|
||||
"open_ai_api_key": "", # openai api key
|
||||
# openai apibase,当use_azure_chatgpt为true时,需要设置对应的api base
|
||||
"open_ai_api_base": "https://api.openai.com/v1",
|
||||
"proxy": "", # openai使用的代理
|
||||
# chatgpt模型, 当use_azure_chatgpt为true时,其名称为Azure上model deployment名称
|
||||
"model": "gpt-3.5-turbo",
|
||||
"use_azure_chatgpt": False, # 是否使用azure的chatgpt
|
||||
"azure_deployment_id": "", # azure 模型部署名称
|
||||
# Bot触发配置
|
||||
"single_chat_prefix": ["bot", "@bot"], # 私聊时文本需要包含该前缀才能触发机器人回复
|
||||
"single_chat_reply_prefix": "[bot] ", # 私聊时自动回复的前缀,用于区分真人
|
||||
"group_chat_prefix": ["@bot"], # 群聊时包含该前缀则会触发机器人回复
|
||||
"group_chat_reply_prefix": "", # 群聊时自动回复的前缀
|
||||
"group_chat_keyword": [], # 群聊时包含该关键词则会触发机器人回复
|
||||
"group_at_off": False, # 是否关闭群聊时@bot的触发
|
||||
"group_name_white_list": ["ChatGPT测试群", "ChatGPT测试群2"], # 开启自动回复的群名称列表
|
||||
"group_name_keyword_white_list": [], # 开启自动回复的群名称关键词列表
|
||||
"group_chat_in_one_session": ["ChatGPT测试群"], # 支持会话上下文共享的群名称
|
||||
"trigger_by_self": False, # 是否允许机器人触发
|
||||
"image_create_prefix": ["画", "看", "找"], # 开启图片回复的前缀
|
||||
"concurrency_in_session": 1, # 同一会话最多有多少条消息在处理中,大于1可能乱序
|
||||
"image_create_size": "256x256", # 图片大小,可选有 256x256, 512x512, 1024x1024
|
||||
# chatgpt会话参数
|
||||
"expires_in_seconds": 3600, # 无操作会话的过期时间
|
||||
"character_desc": "你是ChatGPT, 一个由OpenAI训练的大型语言模型, 你旨在回答并解决人们的任何问题,并且可以使用多种语言与人交流。", # 人格描述
|
||||
"conversation_max_tokens": 1000, # 支持上下文记忆的最多字符数
|
||||
# chatgpt限流配置
|
||||
"rate_limit_chatgpt": 20, # chatgpt的调用频率限制
|
||||
"rate_limit_dalle": 50, # openai dalle的调用频率限制
|
||||
# chatgpt api参数 参考https://platform.openai.com/docs/api-reference/chat/create
|
||||
"temperature": 0.9,
|
||||
"top_p": 1,
|
||||
"frequency_penalty": 0,
|
||||
"presence_penalty": 0,
|
||||
"request_timeout": 60, # chatgpt请求超时时间,openai接口默认设置为600,对于难问题一般需要较长时间
|
||||
"timeout": 120, # chatgpt重试超时时间,在这个时间内,将会自动重试
|
||||
# 语音设置
|
||||
"speech_recognition": False, # 是否开启语音识别
|
||||
"group_speech_recognition": False, # 是否开启群组语音识别
|
||||
"voice_reply_voice": False, # 是否使用语音回复语音,需要设置对应语音合成引擎的api key
|
||||
"always_reply_voice": False, # 是否一直使用语音回复
|
||||
"voice_to_text": "openai", # 语音识别引擎,支持openai,baidu,google,azure
|
||||
"text_to_voice": "baidu", # 语音合成引擎,支持baidu,google,pytts(offline),azure
|
||||
# baidu 语音api配置, 使用百度语音识别和语音合成时需要
|
||||
"baidu_app_id": "",
|
||||
"baidu_api_key": "",
|
||||
"baidu_secret_key": "",
|
||||
# 1536普通话(支持简单的英文识别) 1737英语 1637粤语 1837四川话 1936普通话远场
|
||||
"baidu_dev_pid": "1536",
|
||||
# azure 语音api配置, 使用azure语音识别和语音合成时需要
|
||||
"azure_voice_api_key": "",
|
||||
"azure_voice_region": "japaneast",
|
||||
# 服务时间限制,目前支持itchat
|
||||
"chat_time_module": False, # 是否开启服务时间限制
|
||||
"chat_start_time": "00:00", # 服务开始时间
|
||||
"chat_stop_time": "24:00", # 服务结束时间
|
||||
# itchat的配置
|
||||
"hot_reload": False, # 是否开启热重载
|
||||
# wechaty的配置
|
||||
"wechaty_puppet_service_token": "", # wechaty的token
|
||||
# wechatmp的配置
|
||||
"wechatmp_token": "", # 微信公众平台的Token
|
||||
"wechatmp_port": 8080, # 微信公众平台的端口,需要端口转发到80或443
|
||||
"wechatmp_app_id": "", # 微信公众平台的appID
|
||||
"wechatmp_app_secret": "", # 微信公众平台的appsecret
|
||||
"wechatmp_aes_key": "", # 微信公众平台的EncodingAESKey,加密模式需要
|
||||
# chatgpt指令自定义触发词
|
||||
"clear_memory_commands": ["#清除记忆"], # 重置会话指令,必须以#开头
|
||||
# channel配置
|
||||
"channel_type": "wx", # 通道类型,支持:{wx,wxy,terminal,wechatmp,wechatmp_service}
|
||||
"debug": False, # 是否开启debug模式,开启后会打印更多日志
|
||||
"appdata_dir": "", # 数据目录
|
||||
# 插件配置
|
||||
"plugin_trigger_prefix": "$", # 规范插件提供聊天相关指令的前缀,建议不要和管理员指令前缀"#"冲突
|
||||
}
|
||||
|
||||
|
||||
class Config(dict):
|
||||
def __init__(self, d: dict = {}):
|
||||
super().__init__(d)
|
||||
# user_datas: 用户数据,key为用户名,value为用户数据,也是dict
|
||||
self.user_datas = {}
|
||||
|
||||
def __getitem__(self, key):
|
||||
if key not in available_setting:
|
||||
raise Exception("key {} not in available_setting".format(key))
|
||||
return super().__getitem__(key)
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
if key not in available_setting:
|
||||
raise Exception("key {} not in available_setting".format(key))
|
||||
return super().__setitem__(key, value)
|
||||
|
||||
def get(self, key, default=None):
|
||||
try:
|
||||
return self[key]
|
||||
except KeyError as e:
|
||||
return default
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
# Make sure to return a dictionary to ensure atomic
|
||||
def get_user_data(self, user) -> dict:
|
||||
if self.user_datas.get(user) is None:
|
||||
self.user_datas[user] = {}
|
||||
return self.user_datas[user]
|
||||
|
||||
def load_user_datas(self):
|
||||
try:
|
||||
with open(os.path.join(get_appdata_dir(), "user_datas.pkl"), "rb") as f:
|
||||
self.user_datas = pickle.load(f)
|
||||
logger.info("[Config] User datas loaded.")
|
||||
except FileNotFoundError as e:
|
||||
logger.info("[Config] User datas file not found, ignore.")
|
||||
except Exception as e:
|
||||
logger.info("[Config] User datas error: {}".format(e))
|
||||
self.user_datas = {}
|
||||
|
||||
def save_user_datas(self):
|
||||
try:
|
||||
with open(os.path.join(get_appdata_dir(), "user_datas.pkl"), "wb") as f:
|
||||
pickle.dump(self.user_datas, f)
|
||||
logger.info("[Config] User datas saved.")
|
||||
except Exception as e:
|
||||
logger.info("[Config] User datas error: {}".format(e))
|
||||
|
||||
|
||||
config = Config()
|
||||
|
||||
|
||||
def load_config():
|
||||
global config
|
||||
config_path = "./config.json"
|
||||
if not os.path.exists(config_path):
|
||||
raise Exception('配置文件不存在,请根据config-template.json模板创建config.json文件')
|
||||
logger.info("配置文件不存在,将使用config-template.json模板")
|
||||
config_path = "./config-template.json"
|
||||
|
||||
config_str = read_file(config_path)
|
||||
logger.debug("[INIT] config str: {}".format(config_str))
|
||||
|
||||
# 将json字符串反序列化为dict类型
|
||||
config = json.loads(config_str)
|
||||
config = Config(json.loads(config_str))
|
||||
|
||||
# override config with environment variables.
|
||||
# Some online deployment platforms (e.g. Railway) deploy project from github directly. So you shouldn't put your secrets like api key in a config file, instead use environment variables to override the default config.
|
||||
for name, value in os.environ.items():
|
||||
name = name.lower()
|
||||
if name in available_setting:
|
||||
logger.info("[INIT] override config by environ args: {}={}".format(name, value))
|
||||
try:
|
||||
config[name] = eval(value)
|
||||
except:
|
||||
if value == "false":
|
||||
config[name] = False
|
||||
elif value == "true":
|
||||
config[name] = True
|
||||
else:
|
||||
config[name] = value
|
||||
|
||||
if config.get("debug", False):
|
||||
logger.setLevel(logging.DEBUG)
|
||||
logger.debug("[INIT] set log level to DEBUG")
|
||||
|
||||
logger.info("[INIT] load config: {}".format(config))
|
||||
|
||||
config.load_user_datas()
|
||||
|
||||
|
||||
def get_root():
|
||||
return os.path.dirname(os.path.abspath( __file__ ))
|
||||
return os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def read_file(path):
|
||||
with open(path, mode='r', encoding='utf-8') as f:
|
||||
with open(path, mode="r", encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def conf():
|
||||
return config
|
||||
|
||||
|
||||
def get_appdata_dir():
|
||||
data_path = os.path.join(get_root(), conf().get("appdata_dir", ""))
|
||||
if not os.path.exists(data_path):
|
||||
logger.info("[INIT] data path not exists, create it: {}".format(data_path))
|
||||
os.makedirs(data_path)
|
||||
return data_path
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
FROM python:3.7.9-alpine
|
||||
FROM python:3.10-alpine
|
||||
|
||||
LABEL maintainer="foo@bar.com"
|
||||
ARG TZ='Asia/Shanghai'
|
||||
|
||||
ARG CHATGPT_ON_WECHAT_VER
|
||||
|
||||
ENV BUILD_PREFIX=/app \
|
||||
BUILD_OPEN_AI_API_KEY='YOUR OPEN AI KEY HERE'
|
||||
ENV BUILD_PREFIX=/app
|
||||
|
||||
RUN apk add --no-cache \
|
||||
bash \
|
||||
@@ -22,11 +21,9 @@ RUN apk add --no-cache \
|
||||
&& rm chatgpt-on-wechat-${BUILD_GITHUB_TAG}.tar.gz \
|
||||
&& cd ${BUILD_PREFIX} \
|
||||
&& cp config-template.json ${BUILD_PREFIX}/config.json \
|
||||
&& sed -i "2s/YOUR API KEY/${BUILD_OPEN_AI_API_KEY}/" ${BUILD_PREFIX}/config.json \
|
||||
&& /usr/local/bin/python -m pip install --no-cache --upgrade pip \
|
||||
&& pip install --no-cache \
|
||||
itchat-uos==1.5.0.dev0 \
|
||||
openai \
|
||||
&& pip install --no-cache -r requirements.txt \
|
||||
&& pip install --no-cache -r requirements-optional.txt \
|
||||
&& apk del curl wget
|
||||
|
||||
WORKDIR ${BUILD_PREFIX}
|
||||
@@ -35,7 +32,7 @@ ADD ./entrypoint.sh /entrypoint.sh
|
||||
|
||||
RUN chmod +x /entrypoint.sh \
|
||||
&& adduser -D -h /home/noroot -u 1000 -s /bin/bash noroot \
|
||||
&& chown noroot:noroot ${BUILD_PREFIX}
|
||||
&& chown -R noroot:noroot ${BUILD_PREFIX}
|
||||
|
||||
USER noroot
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
FROM python:3.7.9
|
||||
FROM python:3.10
|
||||
|
||||
LABEL maintainer="foo@bar.com"
|
||||
ARG TZ='Asia/Shanghai'
|
||||
|
||||
ARG CHATGPT_ON_WECHAT_VER
|
||||
|
||||
ENV BUILD_PREFIX=/app \
|
||||
BUILD_OPEN_AI_API_KEY='YOUR OPEN AI KEY HERE'
|
||||
ENV BUILD_PREFIX=/app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
@@ -23,11 +22,9 @@ RUN apt-get update \
|
||||
&& rm chatgpt-on-wechat-${BUILD_GITHUB_TAG}.tar.gz \
|
||||
&& cd ${BUILD_PREFIX} \
|
||||
&& cp config-template.json ${BUILD_PREFIX}/config.json \
|
||||
&& sed -i "2s/YOUR API KEY/${BUILD_OPEN_AI_API_KEY}/" ${BUILD_PREFIX}/config.json \
|
||||
&& /usr/local/bin/python -m pip install --no-cache --upgrade pip \
|
||||
&& pip install --no-cache \
|
||||
itchat-uos==1.5.0.dev0 \
|
||||
openai
|
||||
&& pip install --no-cache -r requirements.txt \
|
||||
&& pip install --no-cache -r requirements-optional.txt
|
||||
|
||||
WORKDIR ${BUILD_PREFIX}
|
||||
|
||||
@@ -36,7 +33,7 @@ ADD ./entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh \
|
||||
&& groupadd -r noroot \
|
||||
&& useradd -r -g noroot -s /bin/bash -d /home/noroot noroot \
|
||||
&& chown -R noroot:noroot ${BUILD_PREFIX}
|
||||
&& chown -R noroot:noroot ${BUILD_PREFIX}
|
||||
|
||||
USER noroot
|
||||
|
||||
|
||||
33
docker/Dockerfile.debian.latest
Normal file
33
docker/Dockerfile.debian.latest
Normal file
@@ -0,0 +1,33 @@
|
||||
FROM python:3.10-slim
|
||||
|
||||
LABEL maintainer="foo@bar.com"
|
||||
ARG TZ='Asia/Shanghai'
|
||||
|
||||
ARG CHATGPT_ON_WECHAT_VER
|
||||
|
||||
ENV BUILD_PREFIX=/app
|
||||
|
||||
ADD . ${BUILD_PREFIX}
|
||||
|
||||
RUN apt-get update \
|
||||
&&apt-get install -y --no-install-recommends bash \
|
||||
ffmpeg espeak \
|
||||
&& cd ${BUILD_PREFIX} \
|
||||
&& cp config-template.json config.json \
|
||||
&& /usr/local/bin/python -m pip install --no-cache --upgrade pip \
|
||||
&& pip install --no-cache -r requirements.txt \
|
||||
&& pip install --no-cache -r requirements-optional.txt \
|
||||
&& pip install azure-cognitiveservices-speech
|
||||
|
||||
WORKDIR ${BUILD_PREFIX}
|
||||
|
||||
ADD docker/entrypoint.sh /entrypoint.sh
|
||||
|
||||
RUN chmod +x /entrypoint.sh \
|
||||
&& groupadd -r noroot \
|
||||
&& useradd -r -g noroot -s /bin/bash -d /home/noroot noroot \
|
||||
&& chown -R noroot:noroot ${BUILD_PREFIX}
|
||||
|
||||
USER noroot
|
||||
|
||||
ENTRYPOINT ["docker/entrypoint.sh"]
|
||||
29
docker/Dockerfile.latest
Normal file
29
docker/Dockerfile.latest
Normal file
@@ -0,0 +1,29 @@
|
||||
FROM python:3.10-alpine
|
||||
|
||||
LABEL maintainer="foo@bar.com"
|
||||
ARG TZ='Asia/Shanghai'
|
||||
|
||||
ARG CHATGPT_ON_WECHAT_VER
|
||||
|
||||
ENV BUILD_PREFIX=/app
|
||||
|
||||
ADD . ${BUILD_PREFIX}
|
||||
|
||||
RUN apk add --no-cache bash ffmpeg espeak \
|
||||
&& cd ${BUILD_PREFIX} \
|
||||
&& cp config-template.json config.json \
|
||||
&& /usr/local/bin/python -m pip install --no-cache --upgrade pip \
|
||||
&& pip install --no-cache -r requirements.txt \
|
||||
&& pip install --no-cache -r requirements-optional.txt
|
||||
|
||||
WORKDIR ${BUILD_PREFIX}
|
||||
|
||||
ADD docker/entrypoint.sh /entrypoint.sh
|
||||
|
||||
RUN chmod +x /entrypoint.sh \
|
||||
&& adduser -D -h /home/noroot -u 1000 -s /bin/bash noroot \
|
||||
&& chown -R noroot:noroot ${BUILD_PREFIX}
|
||||
|
||||
USER noroot
|
||||
|
||||
ENTRYPOINT ["docker/entrypoint.sh"]
|
||||
@@ -11,6 +11,5 @@ docker build -f Dockerfile.alpine \
|
||||
-t zhayujie/chatgpt-on-wechat .
|
||||
|
||||
# tag image
|
||||
docker tag zhayujie/chatgpt-on-wechat zhayujie/chatgpt-on-wechat:alpine
|
||||
docker tag zhayujie/chatgpt-on-wechat zhayujie/chatgpt-on-wechat:alpine
|
||||
docker tag zhayujie/chatgpt-on-wechat zhayujie/chatgpt-on-wechat:$CHATGPT_ON_WECHAT_TAG-alpine
|
||||
|
||||
@@ -11,5 +11,5 @@ docker build -f Dockerfile.debian \
|
||||
-t zhayujie/chatgpt-on-wechat .
|
||||
|
||||
# tag image
|
||||
docker tag zhayujie/chatgpt-on-wechat zhayujie/chatgpt-on-wechat:debian
|
||||
docker tag zhayujie/chatgpt-on-wechat zhayujie/chatgpt-on-wechat:debian
|
||||
docker tag zhayujie/chatgpt-on-wechat zhayujie/chatgpt-on-wechat:$CHATGPT_ON_WECHAT_TAG-debian
|
||||
8
docker/build.latest.sh
Normal file
8
docker/build.latest.sh
Normal file
@@ -0,0 +1,8 @@
|
||||
#!/bin/bash
|
||||
|
||||
unset KUBECONFIG
|
||||
|
||||
cd .. && docker build -f docker/Dockerfile.latest \
|
||||
-t zhayujie/chatgpt-on-wechat .
|
||||
|
||||
docker tag zhayujie/chatgpt-on-wechat zhayujie/chatgpt-on-wechat:$(date +%y%m%d)
|
||||
@@ -9,7 +9,7 @@ RUN apk add --no-cache \
|
||||
ffmpeg \
|
||||
espeak \
|
||||
&& pip install --no-cache \
|
||||
baidu-aip \
|
||||
baidu-aip \
|
||||
chardet \
|
||||
SpeechRecognition
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ RUN apt-get update \
|
||||
ffmpeg \
|
||||
espeak \
|
||||
&& pip install --no-cache \
|
||||
baidu-aip \
|
||||
baidu-aip \
|
||||
chardet \
|
||||
SpeechRecognition
|
||||
|
||||
|
||||
@@ -15,6 +15,6 @@ services:
|
||||
GROUP_NAME_WHITE_LIST: '["ChatGPT测试群", "ChatGPT测试群2"]'
|
||||
IMAGE_CREATE_PREFIX: '["画", "看", "找"]'
|
||||
CONVERSATION_MAX_TOKENS: 1000
|
||||
SPEECH_RECOGNITION: 'false'
|
||||
SPEECH_RECOGNITION: "False"
|
||||
CHARACTER_DESC: '你是ChatGPT, 一个由OpenAI训练的大型语言模型, 你旨在回答并解决人们的任何问题,并且可以使用多种语言与人交流。'
|
||||
EXPIRES_IN_SECONDS: 3600
|
||||
@@ -8,17 +8,19 @@ CHATGPT_ON_WECHAT_CONFIG_PATH=${CHATGPT_ON_WECHAT_CONFIG_PATH:-""}
|
||||
# execution command line
|
||||
CHATGPT_ON_WECHAT_EXEC=${CHATGPT_ON_WECHAT_EXEC:-""}
|
||||
|
||||
OPEN_AI_API_KEY=${OPEN_AI_API_KEY:-""}
|
||||
OPEN_AI_PROXY=${OPEN_AI_PROXY:-""}
|
||||
SINGLE_CHAT_PREFIX=${SINGLE_CHAT_PREFIX:-""}
|
||||
SINGLE_CHAT_REPLY_PREFIX=${SINGLE_CHAT_REPLY_PREFIX:-""}
|
||||
GROUP_CHAT_PREFIX=${GROUP_CHAT_PREFIX:-""}
|
||||
GROUP_NAME_WHITE_LIST=${GROUP_NAME_WHITE_LIST:-""}
|
||||
IMAGE_CREATE_PREFIX=${IMAGE_CREATE_PREFIX:-""}
|
||||
CONVERSATION_MAX_TOKENS=${CONVERSATION_MAX_TOKENS:-""}
|
||||
SPEECH_RECOGNITION=${SPEECH_RECOGNITION:-""}
|
||||
CHARACTER_DESC=${CHARACTER_DESC:-""}
|
||||
EXPIRES_IN_SECONDS=${EXPIRES_IN_SECONDS:-""}
|
||||
# use environment variables to pass parameters
|
||||
# if you have not defined environment variables, set them below
|
||||
# export OPEN_AI_API_KEY=${OPEN_AI_API_KEY:-'YOUR API KEY'}
|
||||
# export OPEN_AI_PROXY=${OPEN_AI_PROXY:-""}
|
||||
# export SINGLE_CHAT_PREFIX=${SINGLE_CHAT_PREFIX:-'["bot", "@bot"]'}
|
||||
# export SINGLE_CHAT_REPLY_PREFIX=${SINGLE_CHAT_REPLY_PREFIX:-'"[bot] "'}
|
||||
# export GROUP_CHAT_PREFIX=${GROUP_CHAT_PREFIX:-'["@bot"]'}
|
||||
# export GROUP_NAME_WHITE_LIST=${GROUP_NAME_WHITE_LIST:-'["ChatGPT测试群", "ChatGPT测试群2"]'}
|
||||
# export IMAGE_CREATE_PREFIX=${IMAGE_CREATE_PREFIX:-'["画", "看", "找"]'}
|
||||
# export CONVERSATION_MAX_TOKENS=${CONVERSATION_MAX_TOKENS:-"1000"}
|
||||
# export SPEECH_RECOGNITION=${SPEECH_RECOGNITION:-"False"}
|
||||
# export CHARACTER_DESC=${CHARACTER_DESC:-"你是ChatGPT, 一个由OpenAI训练的大型语言模型, 你旨在回答并解决人们的任何问题,并且可以使用多种语言与人交流。"}
|
||||
# export EXPIRES_IN_SECONDS=${EXPIRES_IN_SECONDS:-"3600"}
|
||||
|
||||
# CHATGPT_ON_WECHAT_PREFIX is empty, use /app
|
||||
if [ "$CHATGPT_ON_WECHAT_PREFIX" == "" ] ; then
|
||||
@@ -36,56 +38,10 @@ if [ "$CHATGPT_ON_WECHAT_EXEC" == "" ] ; then
|
||||
fi
|
||||
|
||||
# modify content in config.json
|
||||
if [ "$OPEN_AI_API_KEY" != "" ] ; then
|
||||
sed -i "s/\"open_ai_api_key\".*,$/\"open_ai_api_key\": \"$OPEN_AI_API_KEY\",/" $CHATGPT_ON_WECHAT_CONFIG_PATH
|
||||
else
|
||||
if [ "$OPEN_AI_API_KEY" == "YOUR API KEY" ] || [ "$OPEN_AI_API_KEY" == "" ]; then
|
||||
echo -e "\033[31m[Warning] You need to set OPEN_AI_API_KEY before running!\033[0m"
|
||||
fi
|
||||
|
||||
# use http_proxy as default
|
||||
if [ "$HTTP_PROXY" != "" ] ; then
|
||||
sed -i "s/\"proxy\".*,$/\"proxy\": \"$HTTP_PROXY\",/" $CHATGPT_ON_WECHAT_CONFIG_PATH
|
||||
fi
|
||||
|
||||
if [ "$OPEN_AI_PROXY" != "" ] ; then
|
||||
sed -i "s/\"proxy\".*,$/\"proxy\": \"$OPEN_AI_PROXY\",/" $CHATGPT_ON_WECHAT_CONFIG_PATH
|
||||
fi
|
||||
|
||||
if [ "$SINGLE_CHAT_PREFIX" != "" ] ; then
|
||||
sed -i "s/\"single_chat_prefix\".*,$/\"single_chat_prefix\": $SINGLE_CHAT_PREFIX,/" $CHATGPT_ON_WECHAT_CONFIG_PATH
|
||||
fi
|
||||
|
||||
if [ "$SINGLE_CHAT_REPLY_PREFIX" != "" ] ; then
|
||||
sed -i "s/\"single_chat_reply_prefix\".*,$/\"single_chat_reply_prefix\": $SINGLE_CHAT_REPLY_PREFIX,/" $CHATGPT_ON_WECHAT_CONFIG_PATH
|
||||
fi
|
||||
|
||||
if [ "$GROUP_CHAT_PREFIX" != "" ] ; then
|
||||
sed -i "s/\"group_chat_prefix\".*,$/\"group_chat_prefix\": $GROUP_CHAT_PREFIX,/" $CHATGPT_ON_WECHAT_CONFIG_PATH
|
||||
fi
|
||||
|
||||
if [ "$GROUP_NAME_WHITE_LIST" != "" ] ; then
|
||||
sed -i "s/\"group_name_white_list\".*,$/\"group_name_white_list\": $GROUP_NAME_WHITE_LIST,/" $CHATGPT_ON_WECHAT_CONFIG_PATH
|
||||
fi
|
||||
|
||||
if [ "$IMAGE_CREATE_PREFIX" != "" ] ; then
|
||||
sed -i "s/\"image_create_prefix\".*,$/\"image_create_prefix\": $IMAGE_CREATE_PREFIX,/" $CHATGPT_ON_WECHAT_CONFIG_PATH
|
||||
fi
|
||||
|
||||
if [ "$CONVERSATION_MAX_TOKENS" != "" ] ; then
|
||||
sed -i "s/\"conversation_max_tokens\".*,$/\"conversation_max_tokens\": $CONVERSATION_MAX_TOKENS,/" $CHATGPT_ON_WECHAT_CONFIG_PATH
|
||||
fi
|
||||
|
||||
if [ "$SPEECH_RECOGNITION" != "" ] ; then
|
||||
sed -i "s/\"speech_recognition\".*,$/\"speech_recognition\": $SPEECH_RECOGNITION,/" $CHATGPT_ON_WECHAT_CONFIG_PATH
|
||||
fi
|
||||
|
||||
if [ "$CHARACTER_DESC" != "" ] ; then
|
||||
sed -i "s/\"character_desc\".*,$/\"character_desc\": \"$CHARACTER_DESC\",/" $CHATGPT_ON_WECHAT_CONFIG_PATH
|
||||
fi
|
||||
|
||||
if [ "$EXPIRES_IN_SECONDS" != "" ] ; then
|
||||
sed -i "s/\"expires_in_seconds\".*$/\"expires_in_seconds\": $EXPIRES_IN_SECONDS/" $CHATGPT_ON_WECHAT_CONFIG_PATH
|
||||
fi
|
||||
|
||||
# go to prefix dir
|
||||
cd $CHATGPT_ON_WECHAT_PREFIX
|
||||
|
||||
@@ -11,13 +11,13 @@ run_d:
|
||||
docker rm $(CONTAINER_NAME) || echo
|
||||
docker run -dt --name $(CONTAINER_NAME) $(PORT_MAP) \
|
||||
--env-file=$(DOTENV) \
|
||||
$(MOUNT) $(IMG)
|
||||
$(MOUNT) $(IMG)
|
||||
|
||||
run_i:
|
||||
docker rm $(CONTAINER_NAME) || echo
|
||||
docker run -it --name $(CONTAINER_NAME) $(PORT_MAP) \
|
||||
--env-file=$(DOTENV) \
|
||||
$(MOUNT) $(IMG)
|
||||
$(MOUNT) $(IMG)
|
||||
|
||||
stop:
|
||||
docker stop $(CONTAINER_NAME)
|
||||
|
||||
BIN
docs/images/planet.jpg
Normal file
BIN
docs/images/planet.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
96
lib/itchat/__init__.py
Normal file
96
lib/itchat/__init__.py
Normal file
@@ -0,0 +1,96 @@
|
||||
from .core import Core
|
||||
from .config import VERSION, ASYNC_COMPONENTS
|
||||
from .log import set_logging
|
||||
|
||||
if ASYNC_COMPONENTS:
|
||||
from .async_components import load_components
|
||||
else:
|
||||
from .components import load_components
|
||||
|
||||
|
||||
__version__ = VERSION
|
||||
|
||||
|
||||
instanceList = []
|
||||
|
||||
def load_async_itchat() -> Core:
|
||||
"""load async-based itchat instance
|
||||
|
||||
Returns:
|
||||
Core: the abstract interface of itchat
|
||||
"""
|
||||
from .async_components import load_components
|
||||
load_components(Core)
|
||||
return Core()
|
||||
|
||||
|
||||
def load_sync_itchat() -> Core:
|
||||
"""load sync-based itchat instance
|
||||
|
||||
Returns:
|
||||
Core: the abstract interface of itchat
|
||||
"""
|
||||
from .components import load_components
|
||||
load_components(Core)
|
||||
return Core()
|
||||
|
||||
|
||||
if ASYNC_COMPONENTS:
|
||||
instance = load_async_itchat()
|
||||
else:
|
||||
instance = load_sync_itchat()
|
||||
|
||||
|
||||
instanceList = [instance]
|
||||
|
||||
# I really want to use sys.modules[__name__] = originInstance
|
||||
# but it makes auto-fill a real mess, so forgive me for my following **
|
||||
# actually it toke me less than 30 seconds, god bless Uganda
|
||||
|
||||
# components.login
|
||||
login = instance.login
|
||||
get_QRuuid = instance.get_QRuuid
|
||||
get_QR = instance.get_QR
|
||||
check_login = instance.check_login
|
||||
web_init = instance.web_init
|
||||
show_mobile_login = instance.show_mobile_login
|
||||
start_receiving = instance.start_receiving
|
||||
get_msg = instance.get_msg
|
||||
logout = instance.logout
|
||||
# components.contact
|
||||
update_chatroom = instance.update_chatroom
|
||||
update_friend = instance.update_friend
|
||||
get_contact = instance.get_contact
|
||||
get_friends = instance.get_friends
|
||||
get_chatrooms = instance.get_chatrooms
|
||||
get_mps = instance.get_mps
|
||||
set_alias = instance.set_alias
|
||||
set_pinned = instance.set_pinned
|
||||
accept_friend = instance.accept_friend
|
||||
get_head_img = instance.get_head_img
|
||||
create_chatroom = instance.create_chatroom
|
||||
set_chatroom_name = instance.set_chatroom_name
|
||||
delete_member_from_chatroom = instance.delete_member_from_chatroom
|
||||
add_member_into_chatroom = instance.add_member_into_chatroom
|
||||
# components.messages
|
||||
send_raw_msg = instance.send_raw_msg
|
||||
send_msg = instance.send_msg
|
||||
upload_file = instance.upload_file
|
||||
send_file = instance.send_file
|
||||
send_image = instance.send_image
|
||||
send_video = instance.send_video
|
||||
send = instance.send
|
||||
revoke = instance.revoke
|
||||
# components.hotreload
|
||||
dump_login_status = instance.dump_login_status
|
||||
load_login_status = instance.load_login_status
|
||||
# components.register
|
||||
auto_login = instance.auto_login
|
||||
configured_reply = instance.configured_reply
|
||||
msg_register = instance.msg_register
|
||||
run = instance.run
|
||||
# other functions
|
||||
search_friends = instance.search_friends
|
||||
search_chatrooms = instance.search_chatrooms
|
||||
search_mps = instance.search_mps
|
||||
set_logging = set_logging
|
||||
12
lib/itchat/async_components/__init__.py
Normal file
12
lib/itchat/async_components/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from .contact import load_contact
|
||||
from .hotreload import load_hotreload
|
||||
from .login import load_login
|
||||
from .messages import load_messages
|
||||
from .register import load_register
|
||||
|
||||
def load_components(core):
|
||||
load_contact(core)
|
||||
load_hotreload(core)
|
||||
load_login(core)
|
||||
load_messages(core)
|
||||
load_register(core)
|
||||
488
lib/itchat/async_components/contact.py
Normal file
488
lib/itchat/async_components/contact.py
Normal file
@@ -0,0 +1,488 @@
|
||||
import time, re, io
|
||||
import json, copy
|
||||
import logging
|
||||
|
||||
from .. import config, utils
|
||||
from ..components.contact import accept_friend
|
||||
from ..returnvalues import ReturnValue
|
||||
from ..storage import contact_change
|
||||
from ..utils import update_info_dict
|
||||
|
||||
logger = logging.getLogger('itchat')
|
||||
|
||||
def load_contact(core):
|
||||
core.update_chatroom = update_chatroom
|
||||
core.update_friend = update_friend
|
||||
core.get_contact = get_contact
|
||||
core.get_friends = get_friends
|
||||
core.get_chatrooms = get_chatrooms
|
||||
core.get_mps = get_mps
|
||||
core.set_alias = set_alias
|
||||
core.set_pinned = set_pinned
|
||||
core.accept_friend = accept_friend
|
||||
core.get_head_img = get_head_img
|
||||
core.create_chatroom = create_chatroom
|
||||
core.set_chatroom_name = set_chatroom_name
|
||||
core.delete_member_from_chatroom = delete_member_from_chatroom
|
||||
core.add_member_into_chatroom = add_member_into_chatroom
|
||||
|
||||
def update_chatroom(self, userName, detailedMember=False):
|
||||
if not isinstance(userName, list):
|
||||
userName = [userName]
|
||||
url = '%s/webwxbatchgetcontact?type=ex&r=%s' % (
|
||||
self.loginInfo['url'], int(time.time()))
|
||||
headers = {
|
||||
'ContentType': 'application/json; charset=UTF-8',
|
||||
'User-Agent' : config.USER_AGENT }
|
||||
data = {
|
||||
'BaseRequest': self.loginInfo['BaseRequest'],
|
||||
'Count': len(userName),
|
||||
'List': [{
|
||||
'UserName': u,
|
||||
'ChatRoomId': '', } for u in userName], }
|
||||
chatroomList = json.loads(self.s.post(url, data=json.dumps(data), headers=headers
|
||||
).content.decode('utf8', 'replace')).get('ContactList')
|
||||
if not chatroomList:
|
||||
return ReturnValue({'BaseResponse': {
|
||||
'ErrMsg': 'No chatroom found',
|
||||
'Ret': -1001, }})
|
||||
|
||||
if detailedMember:
|
||||
def get_detailed_member_info(encryChatroomId, memberList):
|
||||
url = '%s/webwxbatchgetcontact?type=ex&r=%s' % (
|
||||
self.loginInfo['url'], int(time.time()))
|
||||
headers = {
|
||||
'ContentType': 'application/json; charset=UTF-8',
|
||||
'User-Agent' : config.USER_AGENT, }
|
||||
data = {
|
||||
'BaseRequest': self.loginInfo['BaseRequest'],
|
||||
'Count': len(memberList),
|
||||
'List': [{
|
||||
'UserName': member['UserName'],
|
||||
'EncryChatRoomId': encryChatroomId} \
|
||||
for member in memberList], }
|
||||
return json.loads(self.s.post(url, data=json.dumps(data), headers=headers
|
||||
).content.decode('utf8', 'replace'))['ContactList']
|
||||
MAX_GET_NUMBER = 50
|
||||
for chatroom in chatroomList:
|
||||
totalMemberList = []
|
||||
for i in range(int(len(chatroom['MemberList']) / MAX_GET_NUMBER + 1)):
|
||||
memberList = chatroom['MemberList'][i*MAX_GET_NUMBER: (i+1)*MAX_GET_NUMBER]
|
||||
totalMemberList += get_detailed_member_info(chatroom['EncryChatRoomId'], memberList)
|
||||
chatroom['MemberList'] = totalMemberList
|
||||
|
||||
update_local_chatrooms(self, chatroomList)
|
||||
r = [self.storageClass.search_chatrooms(userName=c['UserName'])
|
||||
for c in chatroomList]
|
||||
return r if 1 < len(r) else r[0]
|
||||
|
||||
def update_friend(self, userName):
|
||||
if not isinstance(userName, list):
|
||||
userName = [userName]
|
||||
url = '%s/webwxbatchgetcontact?type=ex&r=%s' % (
|
||||
self.loginInfo['url'], int(time.time()))
|
||||
headers = {
|
||||
'ContentType': 'application/json; charset=UTF-8',
|
||||
'User-Agent' : config.USER_AGENT }
|
||||
data = {
|
||||
'BaseRequest': self.loginInfo['BaseRequest'],
|
||||
'Count': len(userName),
|
||||
'List': [{
|
||||
'UserName': u,
|
||||
'EncryChatRoomId': '', } for u in userName], }
|
||||
friendList = json.loads(self.s.post(url, data=json.dumps(data), headers=headers
|
||||
).content.decode('utf8', 'replace')).get('ContactList')
|
||||
|
||||
update_local_friends(self, friendList)
|
||||
r = [self.storageClass.search_friends(userName=f['UserName'])
|
||||
for f in friendList]
|
||||
return r if len(r) != 1 else r[0]
|
||||
|
||||
@contact_change
|
||||
def update_local_chatrooms(core, l):
|
||||
'''
|
||||
get a list of chatrooms for updating local chatrooms
|
||||
return a list of given chatrooms with updated info
|
||||
'''
|
||||
for chatroom in l:
|
||||
# format new chatrooms
|
||||
utils.emoji_formatter(chatroom, 'NickName')
|
||||
for member in chatroom['MemberList']:
|
||||
if 'NickName' in member:
|
||||
utils.emoji_formatter(member, 'NickName')
|
||||
if 'DisplayName' in member:
|
||||
utils.emoji_formatter(member, 'DisplayName')
|
||||
if 'RemarkName' in member:
|
||||
utils.emoji_formatter(member, 'RemarkName')
|
||||
# update it to old chatrooms
|
||||
oldChatroom = utils.search_dict_list(
|
||||
core.chatroomList, 'UserName', chatroom['UserName'])
|
||||
if oldChatroom:
|
||||
update_info_dict(oldChatroom, chatroom)
|
||||
# - update other values
|
||||
memberList = chatroom.get('MemberList', [])
|
||||
oldMemberList = oldChatroom['MemberList']
|
||||
if memberList:
|
||||
for member in memberList:
|
||||
oldMember = utils.search_dict_list(
|
||||
oldMemberList, 'UserName', member['UserName'])
|
||||
if oldMember:
|
||||
update_info_dict(oldMember, member)
|
||||
else:
|
||||
oldMemberList.append(member)
|
||||
else:
|
||||
core.chatroomList.append(chatroom)
|
||||
oldChatroom = utils.search_dict_list(
|
||||
core.chatroomList, 'UserName', chatroom['UserName'])
|
||||
# delete useless members
|
||||
if len(chatroom['MemberList']) != len(oldChatroom['MemberList']) and \
|
||||
chatroom['MemberList']:
|
||||
existsUserNames = [member['UserName'] for member in chatroom['MemberList']]
|
||||
delList = []
|
||||
for i, member in enumerate(oldChatroom['MemberList']):
|
||||
if member['UserName'] not in existsUserNames:
|
||||
delList.append(i)
|
||||
delList.sort(reverse=True)
|
||||
for i in delList:
|
||||
del oldChatroom['MemberList'][i]
|
||||
# - update OwnerUin
|
||||
if oldChatroom.get('ChatRoomOwner') and oldChatroom.get('MemberList'):
|
||||
owner = utils.search_dict_list(oldChatroom['MemberList'],
|
||||
'UserName', oldChatroom['ChatRoomOwner'])
|
||||
oldChatroom['OwnerUin'] = (owner or {}).get('Uin', 0)
|
||||
# - update IsAdmin
|
||||
if 'OwnerUin' in oldChatroom and oldChatroom['OwnerUin'] != 0:
|
||||
oldChatroom['IsAdmin'] = \
|
||||
oldChatroom['OwnerUin'] == int(core.loginInfo['wxuin'])
|
||||
else:
|
||||
oldChatroom['IsAdmin'] = None
|
||||
# - update Self
|
||||
newSelf = utils.search_dict_list(oldChatroom['MemberList'],
|
||||
'UserName', core.storageClass.userName)
|
||||
oldChatroom['Self'] = newSelf or copy.deepcopy(core.loginInfo['User'])
|
||||
return {
|
||||
'Type' : 'System',
|
||||
'Text' : [chatroom['UserName'] for chatroom in l],
|
||||
'SystemInfo' : 'chatrooms',
|
||||
'FromUserName' : core.storageClass.userName,
|
||||
'ToUserName' : core.storageClass.userName, }
|
||||
|
||||
@contact_change
|
||||
def update_local_friends(core, l):
|
||||
'''
|
||||
get a list of friends or mps for updating local contact
|
||||
'''
|
||||
fullList = core.memberList + core.mpList
|
||||
for friend in l:
|
||||
if 'NickName' in friend:
|
||||
utils.emoji_formatter(friend, 'NickName')
|
||||
if 'DisplayName' in friend:
|
||||
utils.emoji_formatter(friend, 'DisplayName')
|
||||
if 'RemarkName' in friend:
|
||||
utils.emoji_formatter(friend, 'RemarkName')
|
||||
oldInfoDict = utils.search_dict_list(
|
||||
fullList, 'UserName', friend['UserName'])
|
||||
if oldInfoDict is None:
|
||||
oldInfoDict = copy.deepcopy(friend)
|
||||
if oldInfoDict['VerifyFlag'] & 8 == 0:
|
||||
core.memberList.append(oldInfoDict)
|
||||
else:
|
||||
core.mpList.append(oldInfoDict)
|
||||
else:
|
||||
update_info_dict(oldInfoDict, friend)
|
||||
|
||||
@contact_change
|
||||
def update_local_uin(core, msg):
|
||||
'''
|
||||
content contains uins and StatusNotifyUserName contains username
|
||||
they are in same order, so what I do is to pair them together
|
||||
|
||||
I caught an exception in this method while not knowing why
|
||||
but don't worry, it won't cause any problem
|
||||
'''
|
||||
uins = re.search('<username>([^<]*?)<', msg['Content'])
|
||||
usernameChangedList = []
|
||||
r = {
|
||||
'Type': 'System',
|
||||
'Text': usernameChangedList,
|
||||
'SystemInfo': 'uins', }
|
||||
if uins:
|
||||
uins = uins.group(1).split(',')
|
||||
usernames = msg['StatusNotifyUserName'].split(',')
|
||||
if 0 < len(uins) == len(usernames):
|
||||
for uin, username in zip(uins, usernames):
|
||||
if not '@' in username: continue
|
||||
fullContact = core.memberList + core.chatroomList + core.mpList
|
||||
userDicts = utils.search_dict_list(fullContact,
|
||||
'UserName', username)
|
||||
if userDicts:
|
||||
if userDicts.get('Uin', 0) == 0:
|
||||
userDicts['Uin'] = uin
|
||||
usernameChangedList.append(username)
|
||||
logger.debug('Uin fetched: %s, %s' % (username, uin))
|
||||
else:
|
||||
if userDicts['Uin'] != uin:
|
||||
logger.debug('Uin changed: %s, %s' % (
|
||||
userDicts['Uin'], uin))
|
||||
else:
|
||||
if '@@' in username:
|
||||
core.storageClass.updateLock.release()
|
||||
update_chatroom(core, username)
|
||||
core.storageClass.updateLock.acquire()
|
||||
newChatroomDict = utils.search_dict_list(
|
||||
core.chatroomList, 'UserName', username)
|
||||
if newChatroomDict is None:
|
||||
newChatroomDict = utils.struct_friend_info({
|
||||
'UserName': username,
|
||||
'Uin': uin,
|
||||
'Self': copy.deepcopy(core.loginInfo['User'])})
|
||||
core.chatroomList.append(newChatroomDict)
|
||||
else:
|
||||
newChatroomDict['Uin'] = uin
|
||||
elif '@' in username:
|
||||
core.storageClass.updateLock.release()
|
||||
update_friend(core, username)
|
||||
core.storageClass.updateLock.acquire()
|
||||
newFriendDict = utils.search_dict_list(
|
||||
core.memberList, 'UserName', username)
|
||||
if newFriendDict is None:
|
||||
newFriendDict = utils.struct_friend_info({
|
||||
'UserName': username,
|
||||
'Uin': uin, })
|
||||
core.memberList.append(newFriendDict)
|
||||
else:
|
||||
newFriendDict['Uin'] = uin
|
||||
usernameChangedList.append(username)
|
||||
logger.debug('Uin fetched: %s, %s' % (username, uin))
|
||||
else:
|
||||
logger.debug('Wrong length of uins & usernames: %s, %s' % (
|
||||
len(uins), len(usernames)))
|
||||
else:
|
||||
logger.debug('No uins in 51 message')
|
||||
logger.debug(msg['Content'])
|
||||
return r
|
||||
|
||||
def get_contact(self, update=False):
|
||||
if not update:
|
||||
return utils.contact_deep_copy(self, self.chatroomList)
|
||||
def _get_contact(seq=0):
|
||||
url = '%s/webwxgetcontact?r=%s&seq=%s&skey=%s' % (self.loginInfo['url'],
|
||||
int(time.time()), seq, self.loginInfo['skey'])
|
||||
headers = {
|
||||
'ContentType': 'application/json; charset=UTF-8',
|
||||
'User-Agent' : config.USER_AGENT, }
|
||||
try:
|
||||
r = self.s.get(url, headers=headers)
|
||||
except:
|
||||
logger.info('Failed to fetch contact, that may because of the amount of your chatrooms')
|
||||
for chatroom in self.get_chatrooms():
|
||||
self.update_chatroom(chatroom['UserName'], detailedMember=True)
|
||||
return 0, []
|
||||
j = json.loads(r.content.decode('utf-8', 'replace'))
|
||||
return j.get('Seq', 0), j.get('MemberList')
|
||||
seq, memberList = 0, []
|
||||
while 1:
|
||||
seq, batchMemberList = _get_contact(seq)
|
||||
memberList.extend(batchMemberList)
|
||||
if seq == 0:
|
||||
break
|
||||
chatroomList, otherList = [], []
|
||||
for m in memberList:
|
||||
if m['Sex'] != 0:
|
||||
otherList.append(m)
|
||||
elif '@@' in m['UserName']:
|
||||
chatroomList.append(m)
|
||||
elif '@' in m['UserName']:
|
||||
# mp will be dealt in update_local_friends as well
|
||||
otherList.append(m)
|
||||
if chatroomList:
|
||||
update_local_chatrooms(self, chatroomList)
|
||||
if otherList:
|
||||
update_local_friends(self, otherList)
|
||||
return utils.contact_deep_copy(self, chatroomList)
|
||||
|
||||
def get_friends(self, update=False):
|
||||
if update:
|
||||
self.get_contact(update=True)
|
||||
return utils.contact_deep_copy(self, self.memberList)
|
||||
|
||||
def get_chatrooms(self, update=False, contactOnly=False):
|
||||
if contactOnly:
|
||||
return self.get_contact(update=True)
|
||||
else:
|
||||
if update:
|
||||
self.get_contact(True)
|
||||
return utils.contact_deep_copy(self, self.chatroomList)
|
||||
|
||||
def get_mps(self, update=False):
|
||||
if update: self.get_contact(update=True)
|
||||
return utils.contact_deep_copy(self, self.mpList)
|
||||
|
||||
def set_alias(self, userName, alias):
|
||||
oldFriendInfo = utils.search_dict_list(
|
||||
self.memberList, 'UserName', userName)
|
||||
if oldFriendInfo is None:
|
||||
return ReturnValue({'BaseResponse': {
|
||||
'Ret': -1001, }})
|
||||
url = '%s/webwxoplog?lang=%s&pass_ticket=%s' % (
|
||||
self.loginInfo['url'], 'zh_CN', self.loginInfo['pass_ticket'])
|
||||
data = {
|
||||
'UserName' : userName,
|
||||
'CmdId' : 2,
|
||||
'RemarkName' : alias,
|
||||
'BaseRequest' : self.loginInfo['BaseRequest'], }
|
||||
headers = { 'User-Agent' : config.USER_AGENT}
|
||||
r = self.s.post(url, json.dumps(data, ensure_ascii=False).encode('utf8'),
|
||||
headers=headers)
|
||||
r = ReturnValue(rawResponse=r)
|
||||
if r:
|
||||
oldFriendInfo['RemarkName'] = alias
|
||||
return r
|
||||
|
||||
def set_pinned(self, userName, isPinned=True):
|
||||
url = '%s/webwxoplog?pass_ticket=%s' % (
|
||||
self.loginInfo['url'], self.loginInfo['pass_ticket'])
|
||||
data = {
|
||||
'UserName' : userName,
|
||||
'CmdId' : 3,
|
||||
'OP' : int(isPinned),
|
||||
'BaseRequest' : self.loginInfo['BaseRequest'], }
|
||||
headers = { 'User-Agent' : config.USER_AGENT}
|
||||
r = self.s.post(url, json=data, headers=headers)
|
||||
return ReturnValue(rawResponse=r)
|
||||
|
||||
def accept_friend(self, userName, v4= '', autoUpdate=True):
|
||||
url = f"{self.loginInfo['url']}/webwxverifyuser?r={int(time.time())}&pass_ticket={self.loginInfo['pass_ticket']}"
|
||||
data = {
|
||||
'BaseRequest': self.loginInfo['BaseRequest'],
|
||||
'Opcode': 3, # 3
|
||||
'VerifyUserListSize': 1,
|
||||
'VerifyUserList': [{
|
||||
'Value': userName,
|
||||
'VerifyUserTicket': v4, }],
|
||||
'VerifyContent': '',
|
||||
'SceneListCount': 1,
|
||||
'SceneList': [33],
|
||||
'skey': self.loginInfo['skey'], }
|
||||
headers = {
|
||||
'ContentType': 'application/json; charset=UTF-8',
|
||||
'User-Agent' : config.USER_AGENT }
|
||||
r = self.s.post(url, headers=headers,
|
||||
data=json.dumps(data, ensure_ascii=False).encode('utf8', 'replace'))
|
||||
if autoUpdate:
|
||||
self.update_friend(userName)
|
||||
return ReturnValue(rawResponse=r)
|
||||
|
||||
def get_head_img(self, userName=None, chatroomUserName=None, picDir=None):
|
||||
''' get head image
|
||||
* if you want to get chatroom header: only set chatroomUserName
|
||||
* if you want to get friend header: only set userName
|
||||
* if you want to get chatroom member header: set both
|
||||
'''
|
||||
params = {
|
||||
'userName': userName or chatroomUserName or self.storageClass.userName,
|
||||
'skey': self.loginInfo['skey'],
|
||||
'type': 'big', }
|
||||
url = '%s/webwxgeticon' % self.loginInfo['url']
|
||||
if chatroomUserName is None:
|
||||
infoDict = self.storageClass.search_friends(userName=userName)
|
||||
if infoDict is None:
|
||||
return ReturnValue({'BaseResponse': {
|
||||
'ErrMsg': 'No friend found',
|
||||
'Ret': -1001, }})
|
||||
else:
|
||||
if userName is None:
|
||||
url = '%s/webwxgetheadimg' % self.loginInfo['url']
|
||||
else:
|
||||
chatroom = self.storageClass.search_chatrooms(userName=chatroomUserName)
|
||||
if chatroomUserName is None:
|
||||
return ReturnValue({'BaseResponse': {
|
||||
'ErrMsg': 'No chatroom found',
|
||||
'Ret': -1001, }})
|
||||
if 'EncryChatRoomId' in chatroom:
|
||||
params['chatroomid'] = chatroom['EncryChatRoomId']
|
||||
params['chatroomid'] = params.get('chatroomid') or chatroom['UserName']
|
||||
headers = { 'User-Agent' : config.USER_AGENT}
|
||||
r = self.s.get(url, params=params, stream=True, headers=headers)
|
||||
tempStorage = io.BytesIO()
|
||||
for block in r.iter_content(1024):
|
||||
tempStorage.write(block)
|
||||
if picDir is None:
|
||||
return tempStorage.getvalue()
|
||||
with open(picDir, 'wb') as f:
|
||||
f.write(tempStorage.getvalue())
|
||||
tempStorage.seek(0)
|
||||
return ReturnValue({'BaseResponse': {
|
||||
'ErrMsg': 'Successfully downloaded',
|
||||
'Ret': 0, },
|
||||
'PostFix': utils.get_image_postfix(tempStorage.read(20)), })
|
||||
|
||||
def create_chatroom(self, memberList, topic=''):
|
||||
url = '%s/webwxcreatechatroom?pass_ticket=%s&r=%s' % (
|
||||
self.loginInfo['url'], self.loginInfo['pass_ticket'], int(time.time()))
|
||||
data = {
|
||||
'BaseRequest': self.loginInfo['BaseRequest'],
|
||||
'MemberCount': len(memberList.split(',')),
|
||||
'MemberList': [{'UserName': member} for member in memberList.split(',')],
|
||||
'Topic': topic, }
|
||||
headers = {
|
||||
'content-type': 'application/json; charset=UTF-8',
|
||||
'User-Agent' : config.USER_AGENT }
|
||||
r = self.s.post(url, headers=headers,
|
||||
data=json.dumps(data, ensure_ascii=False).encode('utf8', 'ignore'))
|
||||
return ReturnValue(rawResponse=r)
|
||||
|
||||
def set_chatroom_name(self, chatroomUserName, name):
|
||||
url = '%s/webwxupdatechatroom?fun=modtopic&pass_ticket=%s' % (
|
||||
self.loginInfo['url'], self.loginInfo['pass_ticket'])
|
||||
data = {
|
||||
'BaseRequest': self.loginInfo['BaseRequest'],
|
||||
'ChatRoomName': chatroomUserName,
|
||||
'NewTopic': name, }
|
||||
headers = {
|
||||
'content-type': 'application/json; charset=UTF-8',
|
||||
'User-Agent' : config.USER_AGENT }
|
||||
r = self.s.post(url, headers=headers,
|
||||
data=json.dumps(data, ensure_ascii=False).encode('utf8', 'ignore'))
|
||||
return ReturnValue(rawResponse=r)
|
||||
|
||||
def delete_member_from_chatroom(self, chatroomUserName, memberList):
|
||||
url = '%s/webwxupdatechatroom?fun=delmember&pass_ticket=%s' % (
|
||||
self.loginInfo['url'], self.loginInfo['pass_ticket'])
|
||||
data = {
|
||||
'BaseRequest': self.loginInfo['BaseRequest'],
|
||||
'ChatRoomName': chatroomUserName,
|
||||
'DelMemberList': ','.join([member['UserName'] for member in memberList]), }
|
||||
headers = {
|
||||
'content-type': 'application/json; charset=UTF-8',
|
||||
'User-Agent' : config.USER_AGENT}
|
||||
r = self.s.post(url, data=json.dumps(data),headers=headers)
|
||||
return ReturnValue(rawResponse=r)
|
||||
|
||||
def add_member_into_chatroom(self, chatroomUserName, memberList,
|
||||
useInvitation=False):
|
||||
''' add or invite member into chatroom
|
||||
* there are two ways to get members into chatroom: invite or directly add
|
||||
* but for chatrooms with more than 40 users, you can only use invite
|
||||
* but don't worry we will auto-force userInvitation for you when necessary
|
||||
'''
|
||||
if not useInvitation:
|
||||
chatroom = self.storageClass.search_chatrooms(userName=chatroomUserName)
|
||||
if not chatroom: chatroom = self.update_chatroom(chatroomUserName)
|
||||
if len(chatroom['MemberList']) > self.loginInfo['InviteStartCount']:
|
||||
useInvitation = True
|
||||
if useInvitation:
|
||||
fun, memberKeyName = 'invitemember', 'InviteMemberList'
|
||||
else:
|
||||
fun, memberKeyName = 'addmember', 'AddMemberList'
|
||||
url = '%s/webwxupdatechatroom?fun=%s&pass_ticket=%s' % (
|
||||
self.loginInfo['url'], fun, self.loginInfo['pass_ticket'])
|
||||
params = {
|
||||
'BaseRequest' : self.loginInfo['BaseRequest'],
|
||||
'ChatRoomName' : chatroomUserName,
|
||||
memberKeyName : memberList, }
|
||||
headers = {
|
||||
'content-type': 'application/json; charset=UTF-8',
|
||||
'User-Agent' : config.USER_AGENT}
|
||||
r = self.s.post(url, data=json.dumps(params),headers=headers)
|
||||
return ReturnValue(rawResponse=r)
|
||||
102
lib/itchat/async_components/hotreload.py
Normal file
102
lib/itchat/async_components/hotreload.py
Normal file
@@ -0,0 +1,102 @@
|
||||
import pickle, os
|
||||
import logging
|
||||
|
||||
import requests # type: ignore
|
||||
|
||||
from ..config import VERSION
|
||||
from ..returnvalues import ReturnValue
|
||||
from ..storage import templates
|
||||
from .contact import update_local_chatrooms, update_local_friends
|
||||
from .messages import produce_msg
|
||||
|
||||
logger = logging.getLogger('itchat')
|
||||
|
||||
def load_hotreload(core):
|
||||
core.dump_login_status = dump_login_status
|
||||
core.load_login_status = load_login_status
|
||||
|
||||
async def dump_login_status(self, fileDir=None):
|
||||
fileDir = fileDir or self.hotReloadDir
|
||||
try:
|
||||
with open(fileDir, 'w') as f:
|
||||
f.write('itchat - DELETE THIS')
|
||||
os.remove(fileDir)
|
||||
except:
|
||||
raise Exception('Incorrect fileDir')
|
||||
status = {
|
||||
'version' : VERSION,
|
||||
'loginInfo' : self.loginInfo,
|
||||
'cookies' : self.s.cookies.get_dict(),
|
||||
'storage' : self.storageClass.dumps()}
|
||||
with open(fileDir, 'wb') as f:
|
||||
pickle.dump(status, f)
|
||||
logger.debug('Dump login status for hot reload successfully.')
|
||||
|
||||
async def load_login_status(self, fileDir,
|
||||
loginCallback=None, exitCallback=None):
|
||||
try:
|
||||
with open(fileDir, 'rb') as f:
|
||||
j = pickle.load(f)
|
||||
except Exception as e:
|
||||
logger.debug('No such file, loading login status failed.')
|
||||
return ReturnValue({'BaseResponse': {
|
||||
'ErrMsg': 'No such file, loading login status failed.',
|
||||
'Ret': -1002, }})
|
||||
|
||||
if j.get('version', '') != VERSION:
|
||||
logger.debug(('you have updated itchat from %s to %s, ' +
|
||||
'so cached status is ignored') % (
|
||||
j.get('version', 'old version'), VERSION))
|
||||
return ReturnValue({'BaseResponse': {
|
||||
'ErrMsg': 'cached status ignored because of version',
|
||||
'Ret': -1005, }})
|
||||
self.loginInfo = j['loginInfo']
|
||||
self.loginInfo['User'] = templates.User(self.loginInfo['User'])
|
||||
self.loginInfo['User'].core = self
|
||||
self.s.cookies = requests.utils.cookiejar_from_dict(j['cookies'])
|
||||
self.storageClass.loads(j['storage'])
|
||||
try:
|
||||
msgList, contactList = self.get_msg()
|
||||
except:
|
||||
msgList = contactList = None
|
||||
if (msgList or contactList) is None:
|
||||
self.logout()
|
||||
await load_last_login_status(self.s, j['cookies'])
|
||||
logger.debug('server refused, loading login status failed.')
|
||||
return ReturnValue({'BaseResponse': {
|
||||
'ErrMsg': 'server refused, loading login status failed.',
|
||||
'Ret': -1003, }})
|
||||
else:
|
||||
if contactList:
|
||||
for contact in contactList:
|
||||
if '@@' in contact['UserName']:
|
||||
update_local_chatrooms(self, [contact])
|
||||
else:
|
||||
update_local_friends(self, [contact])
|
||||
if msgList:
|
||||
msgList = produce_msg(self, msgList)
|
||||
for msg in msgList: self.msgList.put(msg)
|
||||
await self.start_receiving(exitCallback)
|
||||
logger.debug('loading login status succeeded.')
|
||||
if hasattr(loginCallback, '__call__'):
|
||||
await loginCallback(self.storageClass.userName)
|
||||
return ReturnValue({'BaseResponse': {
|
||||
'ErrMsg': 'loading login status succeeded.',
|
||||
'Ret': 0, }})
|
||||
|
||||
async def load_last_login_status(session, cookiesDict):
|
||||
try:
|
||||
session.cookies = requests.utils.cookiejar_from_dict({
|
||||
'webwxuvid': cookiesDict['webwxuvid'],
|
||||
'webwx_auth_ticket': cookiesDict['webwx_auth_ticket'],
|
||||
'login_frequency': '2',
|
||||
'last_wxuin': cookiesDict['wxuin'],
|
||||
'wxloadtime': cookiesDict['wxloadtime'] + '_expired',
|
||||
'wxpluginkey': cookiesDict['wxloadtime'],
|
||||
'wxuin': cookiesDict['wxuin'],
|
||||
'mm_lang': 'zh_CN',
|
||||
'MM_WX_NOTIFY_STATE': '1',
|
||||
'MM_WX_SOUND_STATE': '1', })
|
||||
except:
|
||||
logger.info('Load status for push login failed, we may have experienced a cookies change.')
|
||||
logger.info('If you are using the newest version of itchat, you may report a bug.')
|
||||
422
lib/itchat/async_components/login.py
Normal file
422
lib/itchat/async_components/login.py
Normal file
@@ -0,0 +1,422 @@
|
||||
import asyncio
|
||||
import os, time, re, io
|
||||
import threading
|
||||
import json
|
||||
import random
|
||||
import traceback
|
||||
import logging
|
||||
try:
|
||||
from httplib import BadStatusLine
|
||||
except ImportError:
|
||||
from http.client import BadStatusLine
|
||||
|
||||
import requests # type: ignore
|
||||
from pyqrcode import QRCode
|
||||
|
||||
from .. import config, utils
|
||||
from ..returnvalues import ReturnValue
|
||||
from ..storage.templates import wrap_user_dict
|
||||
from .contact import update_local_chatrooms, update_local_friends
|
||||
from .messages import produce_msg
|
||||
|
||||
logger = logging.getLogger('itchat')
|
||||
|
||||
|
||||
def load_login(core):
|
||||
core.login = login
|
||||
core.get_QRuuid = get_QRuuid
|
||||
core.get_QR = get_QR
|
||||
core.check_login = check_login
|
||||
core.web_init = web_init
|
||||
core.show_mobile_login = show_mobile_login
|
||||
core.start_receiving = start_receiving
|
||||
core.get_msg = get_msg
|
||||
core.logout = logout
|
||||
|
||||
async def login(self, enableCmdQR=False, picDir=None, qrCallback=None, EventScanPayload=None,ScanStatus=None,event_stream=None,
|
||||
loginCallback=None, exitCallback=None):
|
||||
if self.alive or self.isLogging:
|
||||
logger.warning('itchat has already logged in.')
|
||||
return
|
||||
self.isLogging = True
|
||||
|
||||
while self.isLogging:
|
||||
uuid = await push_login(self)
|
||||
if uuid:
|
||||
payload = EventScanPayload(
|
||||
status=ScanStatus.Waiting,
|
||||
qrcode=f"qrcode/https://login.weixin.qq.com/l/{uuid}"
|
||||
)
|
||||
event_stream.emit('scan', payload)
|
||||
await asyncio.sleep(0.1)
|
||||
else:
|
||||
logger.info('Getting uuid of QR code.')
|
||||
self.get_QRuuid()
|
||||
payload = EventScanPayload(
|
||||
status=ScanStatus.Waiting,
|
||||
qrcode=f"https://login.weixin.qq.com/l/{self.uuid}"
|
||||
)
|
||||
print(f"https://wechaty.js.org/qrcode/https://login.weixin.qq.com/l/{self.uuid}")
|
||||
event_stream.emit('scan', payload)
|
||||
await asyncio.sleep(0.1)
|
||||
# logger.info('Please scan the QR code to log in.')
|
||||
isLoggedIn = False
|
||||
while not isLoggedIn:
|
||||
status = await self.check_login()
|
||||
# if hasattr(qrCallback, '__call__'):
|
||||
# await qrCallback(uuid=self.uuid, status=status, qrcode=self.qrStorage.getvalue())
|
||||
if status == '200':
|
||||
isLoggedIn = True
|
||||
payload = EventScanPayload(
|
||||
status=ScanStatus.Scanned,
|
||||
qrcode=f"https://login.weixin.qq.com/l/{self.uuid}"
|
||||
)
|
||||
event_stream.emit('scan', payload)
|
||||
await asyncio.sleep(0.1)
|
||||
elif status == '201':
|
||||
if isLoggedIn is not None:
|
||||
logger.info('Please press confirm on your phone.')
|
||||
isLoggedIn = None
|
||||
payload = EventScanPayload(
|
||||
status=ScanStatus.Waiting,
|
||||
qrcode=f"https://login.weixin.qq.com/l/{self.uuid}"
|
||||
)
|
||||
event_stream.emit('scan', payload)
|
||||
await asyncio.sleep(0.1)
|
||||
elif status != '408':
|
||||
payload = EventScanPayload(
|
||||
status=ScanStatus.Cancel,
|
||||
qrcode=f"https://login.weixin.qq.com/l/{self.uuid}"
|
||||
)
|
||||
event_stream.emit('scan', payload)
|
||||
await asyncio.sleep(0.1)
|
||||
break
|
||||
if isLoggedIn:
|
||||
payload = EventScanPayload(
|
||||
status=ScanStatus.Confirmed,
|
||||
qrcode=f"https://login.weixin.qq.com/l/{self.uuid}"
|
||||
)
|
||||
event_stream.emit('scan', payload)
|
||||
await asyncio.sleep(0.1)
|
||||
break
|
||||
elif self.isLogging:
|
||||
logger.info('Log in time out, reloading QR code.')
|
||||
payload = EventScanPayload(
|
||||
status=ScanStatus.Timeout,
|
||||
qrcode=f"https://login.weixin.qq.com/l/{self.uuid}"
|
||||
)
|
||||
event_stream.emit('scan', payload)
|
||||
await asyncio.sleep(0.1)
|
||||
else:
|
||||
return
|
||||
logger.info('Loading the contact, this may take a little while.')
|
||||
await self.web_init()
|
||||
await self.show_mobile_login()
|
||||
self.get_contact(True)
|
||||
if hasattr(loginCallback, '__call__'):
|
||||
r = await loginCallback(self.storageClass.userName)
|
||||
else:
|
||||
utils.clear_screen()
|
||||
if os.path.exists(picDir or config.DEFAULT_QR):
|
||||
os.remove(picDir or config.DEFAULT_QR)
|
||||
logger.info('Login successfully as %s' % self.storageClass.nickName)
|
||||
await self.start_receiving(exitCallback)
|
||||
self.isLogging = False
|
||||
|
||||
async def push_login(core):
|
||||
cookiesDict = core.s.cookies.get_dict()
|
||||
if 'wxuin' in cookiesDict:
|
||||
url = '%s/cgi-bin/mmwebwx-bin/webwxpushloginurl?uin=%s' % (
|
||||
config.BASE_URL, cookiesDict['wxuin'])
|
||||
headers = { 'User-Agent' : config.USER_AGENT}
|
||||
r = core.s.get(url, headers=headers).json()
|
||||
if 'uuid' in r and r.get('ret') in (0, '0'):
|
||||
core.uuid = r['uuid']
|
||||
return r['uuid']
|
||||
return False
|
||||
|
||||
def get_QRuuid(self):
|
||||
url = '%s/jslogin' % config.BASE_URL
|
||||
params = {
|
||||
'appid' : 'wx782c26e4c19acffb',
|
||||
'fun' : 'new',
|
||||
'redirect_uri' : 'https://wx.qq.com/cgi-bin/mmwebwx-bin/webwxnewloginpage?mod=desktop',
|
||||
'lang' : 'zh_CN' }
|
||||
headers = { 'User-Agent' : config.USER_AGENT}
|
||||
r = self.s.get(url, params=params, headers=headers)
|
||||
regx = r'window.QRLogin.code = (\d+); window.QRLogin.uuid = "(\S+?)";'
|
||||
data = re.search(regx, r.text)
|
||||
if data and data.group(1) == '200':
|
||||
self.uuid = data.group(2)
|
||||
return self.uuid
|
||||
|
||||
async def get_QR(self, uuid=None, enableCmdQR=False, picDir=None, qrCallback=None):
|
||||
uuid = uuid or self.uuid
|
||||
picDir = picDir or config.DEFAULT_QR
|
||||
qrStorage = io.BytesIO()
|
||||
qrCode = QRCode('https://login.weixin.qq.com/l/' + uuid)
|
||||
qrCode.png(qrStorage, scale=10)
|
||||
if hasattr(qrCallback, '__call__'):
|
||||
await qrCallback(uuid=uuid, status='0', qrcode=qrStorage.getvalue())
|
||||
else:
|
||||
with open(picDir, 'wb') as f:
|
||||
f.write(qrStorage.getvalue())
|
||||
if enableCmdQR:
|
||||
utils.print_cmd_qr(qrCode.text(1), enableCmdQR=enableCmdQR)
|
||||
else:
|
||||
utils.print_qr(picDir)
|
||||
return qrStorage
|
||||
|
||||
async def check_login(self, uuid=None):
|
||||
uuid = uuid or self.uuid
|
||||
url = '%s/cgi-bin/mmwebwx-bin/login' % config.BASE_URL
|
||||
localTime = int(time.time())
|
||||
params = 'loginicon=true&uuid=%s&tip=1&r=%s&_=%s' % (
|
||||
uuid, int(-localTime / 1579), localTime)
|
||||
headers = { 'User-Agent' : config.USER_AGENT}
|
||||
r = self.s.get(url, params=params, headers=headers)
|
||||
regx = r'window.code=(\d+)'
|
||||
data = re.search(regx, r.text)
|
||||
if data and data.group(1) == '200':
|
||||
if await process_login_info(self, r.text):
|
||||
return '200'
|
||||
else:
|
||||
return '400'
|
||||
elif data:
|
||||
return data.group(1)
|
||||
else:
|
||||
return '400'
|
||||
|
||||
async def process_login_info(core, loginContent):
|
||||
''' when finish login (scanning qrcode)
|
||||
* syncUrl and fileUploadingUrl will be fetched
|
||||
* deviceid and msgid will be generated
|
||||
* skey, wxsid, wxuin, pass_ticket will be fetched
|
||||
'''
|
||||
regx = r'window.redirect_uri="(\S+)";'
|
||||
core.loginInfo['url'] = re.search(regx, loginContent).group(1)
|
||||
headers = { 'User-Agent' : config.USER_AGENT,
|
||||
'client-version' : config.UOS_PATCH_CLIENT_VERSION,
|
||||
'extspam' : config.UOS_PATCH_EXTSPAM,
|
||||
'referer' : 'https://wx.qq.com/?&lang=zh_CN&target=t'
|
||||
}
|
||||
r = core.s.get(core.loginInfo['url'], headers=headers, allow_redirects=False)
|
||||
core.loginInfo['url'] = core.loginInfo['url'][:core.loginInfo['url'].rfind('/')]
|
||||
for indexUrl, detailedUrl in (
|
||||
("wx2.qq.com" , ("file.wx2.qq.com", "webpush.wx2.qq.com")),
|
||||
("wx8.qq.com" , ("file.wx8.qq.com", "webpush.wx8.qq.com")),
|
||||
("qq.com" , ("file.wx.qq.com", "webpush.wx.qq.com")),
|
||||
("web2.wechat.com" , ("file.web2.wechat.com", "webpush.web2.wechat.com")),
|
||||
("wechat.com" , ("file.web.wechat.com", "webpush.web.wechat.com"))):
|
||||
fileUrl, syncUrl = ['https://%s/cgi-bin/mmwebwx-bin' % url for url in detailedUrl]
|
||||
if indexUrl in core.loginInfo['url']:
|
||||
core.loginInfo['fileUrl'], core.loginInfo['syncUrl'] = \
|
||||
fileUrl, syncUrl
|
||||
break
|
||||
else:
|
||||
core.loginInfo['fileUrl'] = core.loginInfo['syncUrl'] = core.loginInfo['url']
|
||||
core.loginInfo['deviceid'] = 'e' + repr(random.random())[2:17]
|
||||
core.loginInfo['logintime'] = int(time.time() * 1e3)
|
||||
core.loginInfo['BaseRequest'] = {}
|
||||
cookies = core.s.cookies.get_dict()
|
||||
skey = re.findall('<skey>(.*?)</skey>', r.text, re.S)[0]
|
||||
pass_ticket = re.findall('<pass_ticket>(.*?)</pass_ticket>', r.text, re.S)[0]
|
||||
core.loginInfo['skey'] = core.loginInfo['BaseRequest']['Skey'] = skey
|
||||
core.loginInfo['wxsid'] = core.loginInfo['BaseRequest']['Sid'] = cookies["wxsid"]
|
||||
core.loginInfo['wxuin'] = core.loginInfo['BaseRequest']['Uin'] = cookies["wxuin"]
|
||||
core.loginInfo['pass_ticket'] = pass_ticket
|
||||
|
||||
# A question : why pass_ticket == DeviceID ?
|
||||
# deviceID is only a randomly generated number
|
||||
|
||||
# UOS PATCH By luvletter2333, Sun Feb 28 10:00 PM
|
||||
# for node in xml.dom.minidom.parseString(r.text).documentElement.childNodes:
|
||||
# if node.nodeName == 'skey':
|
||||
# core.loginInfo['skey'] = core.loginInfo['BaseRequest']['Skey'] = node.childNodes[0].data
|
||||
# elif node.nodeName == 'wxsid':
|
||||
# core.loginInfo['wxsid'] = core.loginInfo['BaseRequest']['Sid'] = node.childNodes[0].data
|
||||
# elif node.nodeName == 'wxuin':
|
||||
# core.loginInfo['wxuin'] = core.loginInfo['BaseRequest']['Uin'] = node.childNodes[0].data
|
||||
# elif node.nodeName == 'pass_ticket':
|
||||
# core.loginInfo['pass_ticket'] = core.loginInfo['BaseRequest']['DeviceID'] = node.childNodes[0].data
|
||||
if not all([key in core.loginInfo for key in ('skey', 'wxsid', 'wxuin', 'pass_ticket')]):
|
||||
logger.error('Your wechat account may be LIMITED to log in WEB wechat, error info:\n%s' % r.text)
|
||||
core.isLogging = False
|
||||
return False
|
||||
return True
|
||||
|
||||
async def web_init(self):
|
||||
url = '%s/webwxinit' % self.loginInfo['url']
|
||||
params = {
|
||||
'r': int(-time.time() / 1579),
|
||||
'pass_ticket': self.loginInfo['pass_ticket'], }
|
||||
data = { 'BaseRequest': self.loginInfo['BaseRequest'], }
|
||||
headers = {
|
||||
'ContentType': 'application/json; charset=UTF-8',
|
||||
'User-Agent' : config.USER_AGENT, }
|
||||
r = self.s.post(url, params=params, data=json.dumps(data), headers=headers)
|
||||
dic = json.loads(r.content.decode('utf-8', 'replace'))
|
||||
# deal with login info
|
||||
utils.emoji_formatter(dic['User'], 'NickName')
|
||||
self.loginInfo['InviteStartCount'] = int(dic['InviteStartCount'])
|
||||
self.loginInfo['User'] = wrap_user_dict(utils.struct_friend_info(dic['User']))
|
||||
self.memberList.append(self.loginInfo['User'])
|
||||
self.loginInfo['SyncKey'] = dic['SyncKey']
|
||||
self.loginInfo['synckey'] = '|'.join(['%s_%s' % (item['Key'], item['Val'])
|
||||
for item in dic['SyncKey']['List']])
|
||||
self.storageClass.userName = dic['User']['UserName']
|
||||
self.storageClass.nickName = dic['User']['NickName']
|
||||
# deal with contact list returned when init
|
||||
contactList = dic.get('ContactList', [])
|
||||
chatroomList, otherList = [], []
|
||||
for m in contactList:
|
||||
if m['Sex'] != 0:
|
||||
otherList.append(m)
|
||||
elif '@@' in m['UserName']:
|
||||
m['MemberList'] = [] # don't let dirty info pollute the list
|
||||
chatroomList.append(m)
|
||||
elif '@' in m['UserName']:
|
||||
# mp will be dealt in update_local_friends as well
|
||||
otherList.append(m)
|
||||
if chatroomList:
|
||||
update_local_chatrooms(self, chatroomList)
|
||||
if otherList:
|
||||
update_local_friends(self, otherList)
|
||||
return dic
|
||||
|
||||
async def show_mobile_login(self):
|
||||
url = '%s/webwxstatusnotify?lang=zh_CN&pass_ticket=%s' % (
|
||||
self.loginInfo['url'], self.loginInfo['pass_ticket'])
|
||||
data = {
|
||||
'BaseRequest' : self.loginInfo['BaseRequest'],
|
||||
'Code' : 3,
|
||||
'FromUserName' : self.storageClass.userName,
|
||||
'ToUserName' : self.storageClass.userName,
|
||||
'ClientMsgId' : int(time.time()), }
|
||||
headers = {
|
||||
'ContentType': 'application/json; charset=UTF-8',
|
||||
'User-Agent' : config.USER_AGENT, }
|
||||
r = self.s.post(url, data=json.dumps(data), headers=headers)
|
||||
return ReturnValue(rawResponse=r)
|
||||
|
||||
async def start_receiving(self, exitCallback=None, getReceivingFnOnly=False):
|
||||
self.alive = True
|
||||
def maintain_loop():
|
||||
retryCount = 0
|
||||
while self.alive:
|
||||
try:
|
||||
i = sync_check(self)
|
||||
if i is None:
|
||||
self.alive = False
|
||||
elif i == '0':
|
||||
pass
|
||||
else:
|
||||
msgList, contactList = self.get_msg()
|
||||
if msgList:
|
||||
msgList = produce_msg(self, msgList)
|
||||
for msg in msgList:
|
||||
self.msgList.put(msg)
|
||||
if contactList:
|
||||
chatroomList, otherList = [], []
|
||||
for contact in contactList:
|
||||
if '@@' in contact['UserName']:
|
||||
chatroomList.append(contact)
|
||||
else:
|
||||
otherList.append(contact)
|
||||
chatroomMsg = update_local_chatrooms(self, chatroomList)
|
||||
chatroomMsg['User'] = self.loginInfo['User']
|
||||
self.msgList.put(chatroomMsg)
|
||||
update_local_friends(self, otherList)
|
||||
retryCount = 0
|
||||
except requests.exceptions.ReadTimeout:
|
||||
pass
|
||||
except:
|
||||
retryCount += 1
|
||||
logger.error(traceback.format_exc())
|
||||
if self.receivingRetryCount < retryCount:
|
||||
self.alive = False
|
||||
else:
|
||||
time.sleep(1)
|
||||
self.logout()
|
||||
if hasattr(exitCallback, '__call__'):
|
||||
exitCallback(self.storageClass.userName)
|
||||
else:
|
||||
logger.info('LOG OUT!')
|
||||
if getReceivingFnOnly:
|
||||
return maintain_loop
|
||||
else:
|
||||
maintainThread = threading.Thread(target=maintain_loop)
|
||||
maintainThread.setDaemon(True)
|
||||
maintainThread.start()
|
||||
|
||||
def sync_check(self):
|
||||
url = '%s/synccheck' % self.loginInfo.get('syncUrl', self.loginInfo['url'])
|
||||
params = {
|
||||
'r' : int(time.time() * 1000),
|
||||
'skey' : self.loginInfo['skey'],
|
||||
'sid' : self.loginInfo['wxsid'],
|
||||
'uin' : self.loginInfo['wxuin'],
|
||||
'deviceid' : self.loginInfo['deviceid'],
|
||||
'synckey' : self.loginInfo['synckey'],
|
||||
'_' : self.loginInfo['logintime'], }
|
||||
headers = { 'User-Agent' : config.USER_AGENT}
|
||||
self.loginInfo['logintime'] += 1
|
||||
try:
|
||||
r = self.s.get(url, params=params, headers=headers, timeout=config.TIMEOUT)
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
try:
|
||||
if not isinstance(e.args[0].args[1], BadStatusLine):
|
||||
raise
|
||||
# will return a package with status '0 -'
|
||||
# and value like:
|
||||
# 6f:00:8a:9c:09:74:e4:d8:e0:14:bf:96:3a:56:a0:64:1b:a4:25:5d:12:f4:31:a5:30:f1:c6:48:5f:c3:75:6a:99:93
|
||||
# seems like status of typing, but before I make further achievement code will remain like this
|
||||
return '2'
|
||||
except:
|
||||
raise
|
||||
r.raise_for_status()
|
||||
regx = r'window.synccheck={retcode:"(\d+)",selector:"(\d+)"}'
|
||||
pm = re.search(regx, r.text)
|
||||
if pm is None or pm.group(1) != '0':
|
||||
logger.debug('Unexpected sync check result: %s' % r.text)
|
||||
return None
|
||||
return pm.group(2)
|
||||
|
||||
def get_msg(self):
|
||||
self.loginInfo['deviceid'] = 'e' + repr(random.random())[2:17]
|
||||
url = '%s/webwxsync?sid=%s&skey=%s&pass_ticket=%s' % (
|
||||
self.loginInfo['url'], self.loginInfo['wxsid'],
|
||||
self.loginInfo['skey'],self.loginInfo['pass_ticket'])
|
||||
data = {
|
||||
'BaseRequest' : self.loginInfo['BaseRequest'],
|
||||
'SyncKey' : self.loginInfo['SyncKey'],
|
||||
'rr' : ~int(time.time()), }
|
||||
headers = {
|
||||
'ContentType': 'application/json; charset=UTF-8',
|
||||
'User-Agent' : config.USER_AGENT }
|
||||
r = self.s.post(url, data=json.dumps(data), headers=headers, timeout=config.TIMEOUT)
|
||||
dic = json.loads(r.content.decode('utf-8', 'replace'))
|
||||
if dic['BaseResponse']['Ret'] != 0: return None, None
|
||||
self.loginInfo['SyncKey'] = dic['SyncKey']
|
||||
self.loginInfo['synckey'] = '|'.join(['%s_%s' % (item['Key'], item['Val'])
|
||||
for item in dic['SyncCheckKey']['List']])
|
||||
return dic['AddMsgList'], dic['ModContactList']
|
||||
|
||||
def logout(self):
|
||||
if self.alive:
|
||||
url = '%s/webwxlogout' % self.loginInfo['url']
|
||||
params = {
|
||||
'redirect' : 1,
|
||||
'type' : 1,
|
||||
'skey' : self.loginInfo['skey'], }
|
||||
headers = { 'User-Agent' : config.USER_AGENT}
|
||||
self.s.get(url, params=params, headers=headers)
|
||||
self.alive = False
|
||||
self.isLogging = False
|
||||
self.s.cookies.clear()
|
||||
del self.chatroomList[:]
|
||||
del self.memberList[:]
|
||||
del self.mpList[:]
|
||||
return ReturnValue({'BaseResponse': {
|
||||
'ErrMsg': 'logout successfully.',
|
||||
'Ret': 0, }})
|
||||
527
lib/itchat/async_components/messages.py
Normal file
527
lib/itchat/async_components/messages.py
Normal file
@@ -0,0 +1,527 @@
|
||||
import os, time, re, io
|
||||
import json
|
||||
import mimetypes, hashlib
|
||||
import logging
|
||||
from collections import OrderedDict
|
||||
|
||||
|
||||
from .. import config, utils
|
||||
from ..returnvalues import ReturnValue
|
||||
from ..storage import templates
|
||||
from .contact import update_local_uin
|
||||
|
||||
logger = logging.getLogger('itchat')
|
||||
|
||||
def load_messages(core):
|
||||
core.send_raw_msg = send_raw_msg
|
||||
core.send_msg = send_msg
|
||||
core.upload_file = upload_file
|
||||
core.send_file = send_file
|
||||
core.send_image = send_image
|
||||
core.send_video = send_video
|
||||
core.send = send
|
||||
core.revoke = revoke
|
||||
|
||||
async def get_download_fn(core, url, msgId):
|
||||
async def download_fn(downloadDir=None):
|
||||
params = {
|
||||
'msgid': msgId,
|
||||
'skey': core.loginInfo['skey'],}
|
||||
headers = { 'User-Agent' : config.USER_AGENT}
|
||||
r = core.s.get(url, params=params, stream=True, headers = headers)
|
||||
tempStorage = io.BytesIO()
|
||||
for block in r.iter_content(1024):
|
||||
tempStorage.write(block)
|
||||
if downloadDir is None:
|
||||
return tempStorage.getvalue()
|
||||
with open(downloadDir, 'wb') as f:
|
||||
f.write(tempStorage.getvalue())
|
||||
tempStorage.seek(0)
|
||||
return ReturnValue({'BaseResponse': {
|
||||
'ErrMsg': 'Successfully downloaded',
|
||||
'Ret': 0, },
|
||||
'PostFix': utils.get_image_postfix(tempStorage.read(20)), })
|
||||
return download_fn
|
||||
|
||||
def produce_msg(core, msgList):
|
||||
''' for messages types
|
||||
* 40 msg, 43 videochat, 50 VOIPMSG, 52 voipnotifymsg
|
||||
* 53 webwxvoipnotifymsg, 9999 sysnotice
|
||||
'''
|
||||
rl = []
|
||||
srl = [40, 43, 50, 52, 53, 9999]
|
||||
for m in msgList:
|
||||
# get actual opposite
|
||||
if m['FromUserName'] == core.storageClass.userName:
|
||||
actualOpposite = m['ToUserName']
|
||||
else:
|
||||
actualOpposite = m['FromUserName']
|
||||
# produce basic message
|
||||
if '@@' in m['FromUserName'] or '@@' in m['ToUserName']:
|
||||
produce_group_chat(core, m)
|
||||
else:
|
||||
utils.msg_formatter(m, 'Content')
|
||||
# set user of msg
|
||||
if '@@' in actualOpposite:
|
||||
m['User'] = core.search_chatrooms(userName=actualOpposite) or \
|
||||
templates.Chatroom({'UserName': actualOpposite})
|
||||
# we don't need to update chatroom here because we have
|
||||
# updated once when producing basic message
|
||||
elif actualOpposite in ('filehelper', 'fmessage'):
|
||||
m['User'] = templates.User({'UserName': actualOpposite})
|
||||
else:
|
||||
m['User'] = core.search_mps(userName=actualOpposite) or \
|
||||
core.search_friends(userName=actualOpposite) or \
|
||||
templates.User(userName=actualOpposite)
|
||||
# by default we think there may be a user missing not a mp
|
||||
m['User'].core = core
|
||||
if m['MsgType'] == 1: # words
|
||||
if m['Url']:
|
||||
regx = r'(.+?\(.+?\))'
|
||||
data = re.search(regx, m['Content'])
|
||||
data = 'Map' if data is None else data.group(1)
|
||||
msg = {
|
||||
'Type': 'Map',
|
||||
'Text': data,}
|
||||
else:
|
||||
msg = {
|
||||
'Type': 'Text',
|
||||
'Text': m['Content'],}
|
||||
elif m['MsgType'] == 3 or m['MsgType'] == 47: # picture
|
||||
download_fn = get_download_fn(core,
|
||||
'%s/webwxgetmsgimg' % core.loginInfo['url'], m['NewMsgId'])
|
||||
msg = {
|
||||
'Type' : 'Picture',
|
||||
'FileName' : '%s.%s' % (time.strftime('%y%m%d-%H%M%S', time.localtime()),
|
||||
'png' if m['MsgType'] == 3 else 'gif'),
|
||||
'Text' : download_fn, }
|
||||
elif m['MsgType'] == 34: # voice
|
||||
download_fn = get_download_fn(core,
|
||||
'%s/webwxgetvoice' % core.loginInfo['url'], m['NewMsgId'])
|
||||
msg = {
|
||||
'Type': 'Recording',
|
||||
'FileName' : '%s.mp3' % time.strftime('%y%m%d-%H%M%S', time.localtime()),
|
||||
'Text': download_fn,}
|
||||
elif m['MsgType'] == 37: # friends
|
||||
m['User']['UserName'] = m['RecommendInfo']['UserName']
|
||||
msg = {
|
||||
'Type': 'Friends',
|
||||
'Text': {
|
||||
'status' : m['Status'],
|
||||
'userName' : m['RecommendInfo']['UserName'],
|
||||
'verifyContent' : m['Ticket'],
|
||||
'autoUpdate' : m['RecommendInfo'], }, }
|
||||
m['User'].verifyDict = msg['Text']
|
||||
elif m['MsgType'] == 42: # name card
|
||||
msg = {
|
||||
'Type': 'Card',
|
||||
'Text': m['RecommendInfo'], }
|
||||
elif m['MsgType'] in (43, 62): # tiny video
|
||||
msgId = m['MsgId']
|
||||
async def download_video(videoDir=None):
|
||||
url = '%s/webwxgetvideo' % core.loginInfo['url']
|
||||
params = {
|
||||
'msgid': msgId,
|
||||
'skey': core.loginInfo['skey'],}
|
||||
headers = {'Range': 'bytes=0-', 'User-Agent' : config.USER_AGENT}
|
||||
r = core.s.get(url, params=params, headers=headers, stream=True)
|
||||
tempStorage = io.BytesIO()
|
||||
for block in r.iter_content(1024):
|
||||
tempStorage.write(block)
|
||||
if videoDir is None:
|
||||
return tempStorage.getvalue()
|
||||
with open(videoDir, 'wb') as f:
|
||||
f.write(tempStorage.getvalue())
|
||||
return ReturnValue({'BaseResponse': {
|
||||
'ErrMsg': 'Successfully downloaded',
|
||||
'Ret': 0, }})
|
||||
msg = {
|
||||
'Type': 'Video',
|
||||
'FileName' : '%s.mp4' % time.strftime('%y%m%d-%H%M%S', time.localtime()),
|
||||
'Text': download_video, }
|
||||
elif m['MsgType'] == 49: # sharing
|
||||
if m['AppMsgType'] == 0: # chat history
|
||||
msg = {
|
||||
'Type': 'Note',
|
||||
'Text': m['Content'], }
|
||||
elif m['AppMsgType'] == 6:
|
||||
rawMsg = m
|
||||
cookiesList = {name:data for name,data in core.s.cookies.items()}
|
||||
async def download_atta(attaDir=None):
|
||||
url = core.loginInfo['fileUrl'] + '/webwxgetmedia'
|
||||
params = {
|
||||
'sender': rawMsg['FromUserName'],
|
||||
'mediaid': rawMsg['MediaId'],
|
||||
'filename': rawMsg['FileName'],
|
||||
'fromuser': core.loginInfo['wxuin'],
|
||||
'pass_ticket': 'undefined',
|
||||
'webwx_data_ticket': cookiesList['webwx_data_ticket'],}
|
||||
headers = { 'User-Agent' : config.USER_AGENT}
|
||||
r = core.s.get(url, params=params, stream=True, headers=headers)
|
||||
tempStorage = io.BytesIO()
|
||||
for block in r.iter_content(1024):
|
||||
tempStorage.write(block)
|
||||
if attaDir is None:
|
||||
return tempStorage.getvalue()
|
||||
with open(attaDir, 'wb') as f:
|
||||
f.write(tempStorage.getvalue())
|
||||
return ReturnValue({'BaseResponse': {
|
||||
'ErrMsg': 'Successfully downloaded',
|
||||
'Ret': 0, }})
|
||||
msg = {
|
||||
'Type': 'Attachment',
|
||||
'Text': download_atta, }
|
||||
elif m['AppMsgType'] == 8:
|
||||
download_fn = get_download_fn(core,
|
||||
'%s/webwxgetmsgimg' % core.loginInfo['url'], m['NewMsgId'])
|
||||
msg = {
|
||||
'Type' : 'Picture',
|
||||
'FileName' : '%s.gif' % (
|
||||
time.strftime('%y%m%d-%H%M%S', time.localtime())),
|
||||
'Text' : download_fn, }
|
||||
elif m['AppMsgType'] == 17:
|
||||
msg = {
|
||||
'Type': 'Note',
|
||||
'Text': m['FileName'], }
|
||||
elif m['AppMsgType'] == 2000:
|
||||
regx = r'\[CDATA\[(.+?)\][\s\S]+?\[CDATA\[(.+?)\]'
|
||||
data = re.search(regx, m['Content'])
|
||||
if data:
|
||||
data = data.group(2).split(u'\u3002')[0]
|
||||
else:
|
||||
data = 'You may found detailed info in Content key.'
|
||||
msg = {
|
||||
'Type': 'Note',
|
||||
'Text': data, }
|
||||
else:
|
||||
msg = {
|
||||
'Type': 'Sharing',
|
||||
'Text': m['FileName'], }
|
||||
elif m['MsgType'] == 51: # phone init
|
||||
msg = update_local_uin(core, m)
|
||||
elif m['MsgType'] == 10000:
|
||||
msg = {
|
||||
'Type': 'Note',
|
||||
'Text': m['Content'],}
|
||||
elif m['MsgType'] == 10002:
|
||||
regx = r'\[CDATA\[(.+?)\]\]'
|
||||
data = re.search(regx, m['Content'])
|
||||
data = 'System message' if data is None else data.group(1).replace('\\', '')
|
||||
msg = {
|
||||
'Type': 'Note',
|
||||
'Text': data, }
|
||||
elif m['MsgType'] in srl:
|
||||
msg = {
|
||||
'Type': 'Useless',
|
||||
'Text': 'UselessMsg', }
|
||||
else:
|
||||
logger.debug('Useless message received: %s\n%s' % (m['MsgType'], str(m)))
|
||||
msg = {
|
||||
'Type': 'Useless',
|
||||
'Text': 'UselessMsg', }
|
||||
m = dict(m, **msg)
|
||||
rl.append(m)
|
||||
return rl
|
||||
|
||||
def produce_group_chat(core, msg):
|
||||
r = re.match('(@[0-9a-z]*?):<br/>(.*)$', msg['Content'])
|
||||
if r:
|
||||
actualUserName, content = r.groups()
|
||||
chatroomUserName = msg['FromUserName']
|
||||
elif msg['FromUserName'] == core.storageClass.userName:
|
||||
actualUserName = core.storageClass.userName
|
||||
content = msg['Content']
|
||||
chatroomUserName = msg['ToUserName']
|
||||
else:
|
||||
msg['ActualUserName'] = core.storageClass.userName
|
||||
msg['ActualNickName'] = core.storageClass.nickName
|
||||
msg['IsAt'] = False
|
||||
utils.msg_formatter(msg, 'Content')
|
||||
return
|
||||
chatroom = core.storageClass.search_chatrooms(userName=chatroomUserName)
|
||||
member = utils.search_dict_list((chatroom or {}).get(
|
||||
'MemberList') or [], 'UserName', actualUserName)
|
||||
if member is None:
|
||||
chatroom = core.update_chatroom(chatroomUserName)
|
||||
member = utils.search_dict_list((chatroom or {}).get(
|
||||
'MemberList') or [], 'UserName', actualUserName)
|
||||
if member is None:
|
||||
logger.debug('chatroom member fetch failed with %s' % actualUserName)
|
||||
msg['ActualNickName'] = ''
|
||||
msg['IsAt'] = False
|
||||
else:
|
||||
msg['ActualNickName'] = member.get('DisplayName', '') or member['NickName']
|
||||
atFlag = '@' + (chatroom['Self'].get('DisplayName', '') or core.storageClass.nickName)
|
||||
msg['IsAt'] = (
|
||||
(atFlag + (u'\u2005' if u'\u2005' in msg['Content'] else ' '))
|
||||
in msg['Content'] or msg['Content'].endswith(atFlag))
|
||||
msg['ActualUserName'] = actualUserName
|
||||
msg['Content'] = content
|
||||
utils.msg_formatter(msg, 'Content')
|
||||
|
||||
async def send_raw_msg(self, msgType, content, toUserName):
|
||||
url = '%s/webwxsendmsg' % self.loginInfo['url']
|
||||
data = {
|
||||
'BaseRequest': self.loginInfo['BaseRequest'],
|
||||
'Msg': {
|
||||
'Type': msgType,
|
||||
'Content': content,
|
||||
'FromUserName': self.storageClass.userName,
|
||||
'ToUserName': (toUserName if toUserName else self.storageClass.userName),
|
||||
'LocalID': int(time.time() * 1e4),
|
||||
'ClientMsgId': int(time.time() * 1e4),
|
||||
},
|
||||
'Scene': 0, }
|
||||
headers = { 'ContentType': 'application/json; charset=UTF-8', 'User-Agent' : config.USER_AGENT}
|
||||
r = self.s.post(url, headers=headers,
|
||||
data=json.dumps(data, ensure_ascii=False).encode('utf8'))
|
||||
return ReturnValue(rawResponse=r)
|
||||
|
||||
async def send_msg(self, msg='Test Message', toUserName=None):
|
||||
logger.debug('Request to send a text message to %s: %s' % (toUserName, msg))
|
||||
r = await self.send_raw_msg(1, msg, toUserName)
|
||||
return r
|
||||
|
||||
def _prepare_file(fileDir, file_=None):
|
||||
fileDict = {}
|
||||
if file_:
|
||||
if hasattr(file_, 'read'):
|
||||
file_ = file_.read()
|
||||
else:
|
||||
return ReturnValue({'BaseResponse': {
|
||||
'ErrMsg': 'file_ param should be opened file',
|
||||
'Ret': -1005, }})
|
||||
else:
|
||||
if not utils.check_file(fileDir):
|
||||
return ReturnValue({'BaseResponse': {
|
||||
'ErrMsg': 'No file found in specific dir',
|
||||
'Ret': -1002, }})
|
||||
with open(fileDir, 'rb') as f:
|
||||
file_ = f.read()
|
||||
fileDict['fileSize'] = len(file_)
|
||||
fileDict['fileMd5'] = hashlib.md5(file_).hexdigest()
|
||||
fileDict['file_'] = io.BytesIO(file_)
|
||||
return fileDict
|
||||
|
||||
def upload_file(self, fileDir, isPicture=False, isVideo=False,
|
||||
toUserName='filehelper', file_=None, preparedFile=None):
|
||||
logger.debug('Request to upload a %s: %s' % (
|
||||
'picture' if isPicture else 'video' if isVideo else 'file', fileDir))
|
||||
if not preparedFile:
|
||||
preparedFile = _prepare_file(fileDir, file_)
|
||||
if not preparedFile:
|
||||
return preparedFile
|
||||
fileSize, fileMd5, file_ = \
|
||||
preparedFile['fileSize'], preparedFile['fileMd5'], preparedFile['file_']
|
||||
fileSymbol = 'pic' if isPicture else 'video' if isVideo else'doc'
|
||||
chunks = int((fileSize - 1) / 524288) + 1
|
||||
clientMediaId = int(time.time() * 1e4)
|
||||
uploadMediaRequest = json.dumps(OrderedDict([
|
||||
('UploadType', 2),
|
||||
('BaseRequest', self.loginInfo['BaseRequest']),
|
||||
('ClientMediaId', clientMediaId),
|
||||
('TotalLen', fileSize),
|
||||
('StartPos', 0),
|
||||
('DataLen', fileSize),
|
||||
('MediaType', 4),
|
||||
('FromUserName', self.storageClass.userName),
|
||||
('ToUserName', toUserName),
|
||||
('FileMd5', fileMd5)]
|
||||
), separators = (',', ':'))
|
||||
r = {'BaseResponse': {'Ret': -1005, 'ErrMsg': 'Empty file detected'}}
|
||||
for chunk in range(chunks):
|
||||
r = upload_chunk_file(self, fileDir, fileSymbol, fileSize,
|
||||
file_, chunk, chunks, uploadMediaRequest)
|
||||
file_.close()
|
||||
if isinstance(r, dict):
|
||||
return ReturnValue(r)
|
||||
return ReturnValue(rawResponse=r)
|
||||
|
||||
def upload_chunk_file(core, fileDir, fileSymbol, fileSize,
|
||||
file_, chunk, chunks, uploadMediaRequest):
|
||||
url = core.loginInfo.get('fileUrl', core.loginInfo['url']) + \
|
||||
'/webwxuploadmedia?f=json'
|
||||
# save it on server
|
||||
cookiesList = {name:data for name,data in core.s.cookies.items()}
|
||||
fileType = mimetypes.guess_type(fileDir)[0] or 'application/octet-stream'
|
||||
fileName = utils.quote(os.path.basename(fileDir))
|
||||
files = OrderedDict([
|
||||
('id', (None, 'WU_FILE_0')),
|
||||
('name', (None, fileName)),
|
||||
('type', (None, fileType)),
|
||||
('lastModifiedDate', (None, time.strftime('%a %b %d %Y %H:%M:%S GMT+0800 (CST)'))),
|
||||
('size', (None, str(fileSize))),
|
||||
('chunks', (None, None)),
|
||||
('chunk', (None, None)),
|
||||
('mediatype', (None, fileSymbol)),
|
||||
('uploadmediarequest', (None, uploadMediaRequest)),
|
||||
('webwx_data_ticket', (None, cookiesList['webwx_data_ticket'])),
|
||||
('pass_ticket', (None, core.loginInfo['pass_ticket'])),
|
||||
('filename' , (fileName, file_.read(524288), 'application/octet-stream'))])
|
||||
if chunks == 1:
|
||||
del files['chunk']; del files['chunks']
|
||||
else:
|
||||
files['chunk'], files['chunks'] = (None, str(chunk)), (None, str(chunks))
|
||||
headers = { 'User-Agent' : config.USER_AGENT}
|
||||
return core.s.post(url, files=files, headers=headers, timeout=config.TIMEOUT)
|
||||
|
||||
async def send_file(self, fileDir, toUserName=None, mediaId=None, file_=None):
|
||||
logger.debug('Request to send a file(mediaId: %s) to %s: %s' % (
|
||||
mediaId, toUserName, fileDir))
|
||||
if hasattr(fileDir, 'read'):
|
||||
return ReturnValue({'BaseResponse': {
|
||||
'ErrMsg': 'fileDir param should not be an opened file in send_file',
|
||||
'Ret': -1005, }})
|
||||
if toUserName is None:
|
||||
toUserName = self.storageClass.userName
|
||||
preparedFile = _prepare_file(fileDir, file_)
|
||||
if not preparedFile:
|
||||
return preparedFile
|
||||
fileSize = preparedFile['fileSize']
|
||||
if mediaId is None:
|
||||
r = self.upload_file(fileDir, preparedFile=preparedFile)
|
||||
if r:
|
||||
mediaId = r['MediaId']
|
||||
else:
|
||||
return r
|
||||
url = '%s/webwxsendappmsg?fun=async&f=json' % self.loginInfo['url']
|
||||
data = {
|
||||
'BaseRequest': self.loginInfo['BaseRequest'],
|
||||
'Msg': {
|
||||
'Type': 6,
|
||||
'Content': ("<appmsg appid='wxeb7ec651dd0aefa9' sdkver=''><title>%s</title>" % os.path.basename(fileDir) +
|
||||
"<des></des><action></action><type>6</type><content></content><url></url><lowurl></lowurl>" +
|
||||
"<appattach><totallen>%s</totallen><attachid>%s</attachid>" % (str(fileSize), mediaId) +
|
||||
"<fileext>%s</fileext></appattach><extinfo></extinfo></appmsg>" % os.path.splitext(fileDir)[1].replace('.','')),
|
||||
'FromUserName': self.storageClass.userName,
|
||||
'ToUserName': toUserName,
|
||||
'LocalID': int(time.time() * 1e4),
|
||||
'ClientMsgId': int(time.time() * 1e4), },
|
||||
'Scene': 0, }
|
||||
headers = {
|
||||
'User-Agent': config.USER_AGENT,
|
||||
'Content-Type': 'application/json;charset=UTF-8', }
|
||||
r = self.s.post(url, headers=headers,
|
||||
data=json.dumps(data, ensure_ascii=False).encode('utf8'))
|
||||
return ReturnValue(rawResponse=r)
|
||||
|
||||
async def send_image(self, fileDir=None, toUserName=None, mediaId=None, file_=None):
|
||||
logger.debug('Request to send a image(mediaId: %s) to %s: %s' % (
|
||||
mediaId, toUserName, fileDir))
|
||||
if fileDir or file_:
|
||||
if hasattr(fileDir, 'read'):
|
||||
file_, fileDir = fileDir, None
|
||||
if fileDir is None:
|
||||
fileDir = 'tmp.jpg' # specific fileDir to send gifs
|
||||
else:
|
||||
return ReturnValue({'BaseResponse': {
|
||||
'ErrMsg': 'Either fileDir or file_ should be specific',
|
||||
'Ret': -1005, }})
|
||||
if toUserName is None:
|
||||
toUserName = self.storageClass.userName
|
||||
if mediaId is None:
|
||||
r = self.upload_file(fileDir, isPicture=not fileDir[-4:] == '.gif', file_=file_)
|
||||
if r:
|
||||
mediaId = r['MediaId']
|
||||
else:
|
||||
return r
|
||||
url = '%s/webwxsendmsgimg?fun=async&f=json' % self.loginInfo['url']
|
||||
data = {
|
||||
'BaseRequest': self.loginInfo['BaseRequest'],
|
||||
'Msg': {
|
||||
'Type': 3,
|
||||
'MediaId': mediaId,
|
||||
'FromUserName': self.storageClass.userName,
|
||||
'ToUserName': toUserName,
|
||||
'LocalID': int(time.time() * 1e4),
|
||||
'ClientMsgId': int(time.time() * 1e4), },
|
||||
'Scene': 0, }
|
||||
if fileDir[-4:] == '.gif':
|
||||
url = '%s/webwxsendemoticon?fun=sys' % self.loginInfo['url']
|
||||
data['Msg']['Type'] = 47
|
||||
data['Msg']['EmojiFlag'] = 2
|
||||
headers = {
|
||||
'User-Agent': config.USER_AGENT,
|
||||
'Content-Type': 'application/json;charset=UTF-8', }
|
||||
r = self.s.post(url, headers=headers,
|
||||
data=json.dumps(data, ensure_ascii=False).encode('utf8'))
|
||||
return ReturnValue(rawResponse=r)
|
||||
|
||||
async def send_video(self, fileDir=None, toUserName=None, mediaId=None, file_=None):
|
||||
logger.debug('Request to send a video(mediaId: %s) to %s: %s' % (
|
||||
mediaId, toUserName, fileDir))
|
||||
if fileDir or file_:
|
||||
if hasattr(fileDir, 'read'):
|
||||
file_, fileDir = fileDir, None
|
||||
if fileDir is None:
|
||||
fileDir = 'tmp.mp4' # specific fileDir to send other formats
|
||||
else:
|
||||
return ReturnValue({'BaseResponse': {
|
||||
'ErrMsg': 'Either fileDir or file_ should be specific',
|
||||
'Ret': -1005, }})
|
||||
if toUserName is None:
|
||||
toUserName = self.storageClass.userName
|
||||
if mediaId is None:
|
||||
r = self.upload_file(fileDir, isVideo=True, file_=file_)
|
||||
if r:
|
||||
mediaId = r['MediaId']
|
||||
else:
|
||||
return r
|
||||
url = '%s/webwxsendvideomsg?fun=async&f=json&pass_ticket=%s' % (
|
||||
self.loginInfo['url'], self.loginInfo['pass_ticket'])
|
||||
data = {
|
||||
'BaseRequest': self.loginInfo['BaseRequest'],
|
||||
'Msg': {
|
||||
'Type' : 43,
|
||||
'MediaId' : mediaId,
|
||||
'FromUserName' : self.storageClass.userName,
|
||||
'ToUserName' : toUserName,
|
||||
'LocalID' : int(time.time() * 1e4),
|
||||
'ClientMsgId' : int(time.time() * 1e4), },
|
||||
'Scene': 0, }
|
||||
headers = {
|
||||
'User-Agent' : config.USER_AGENT,
|
||||
'Content-Type': 'application/json;charset=UTF-8', }
|
||||
r = self.s.post(url, headers=headers,
|
||||
data=json.dumps(data, ensure_ascii=False).encode('utf8'))
|
||||
return ReturnValue(rawResponse=r)
|
||||
|
||||
async def send(self, msg, toUserName=None, mediaId=None):
|
||||
if not msg:
|
||||
r = ReturnValue({'BaseResponse': {
|
||||
'ErrMsg': 'No message.',
|
||||
'Ret': -1005, }})
|
||||
elif msg[:5] == '@fil@':
|
||||
if mediaId is None:
|
||||
r = await self.send_file(msg[5:], toUserName)
|
||||
else:
|
||||
r = await self.send_file(msg[5:], toUserName, mediaId)
|
||||
elif msg[:5] == '@img@':
|
||||
if mediaId is None:
|
||||
r = await self.send_image(msg[5:], toUserName)
|
||||
else:
|
||||
r = await self.send_image(msg[5:], toUserName, mediaId)
|
||||
elif msg[:5] == '@msg@':
|
||||
r = await self.send_msg(msg[5:], toUserName)
|
||||
elif msg[:5] == '@vid@':
|
||||
if mediaId is None:
|
||||
r = await self.send_video(msg[5:], toUserName)
|
||||
else:
|
||||
r = await self.send_video(msg[5:], toUserName, mediaId)
|
||||
else:
|
||||
r = await self.send_msg(msg, toUserName)
|
||||
return r
|
||||
|
||||
async def revoke(self, msgId, toUserName, localId=None):
|
||||
url = '%s/webwxrevokemsg' % self.loginInfo['url']
|
||||
data = {
|
||||
'BaseRequest': self.loginInfo['BaseRequest'],
|
||||
"ClientMsgId": localId or str(time.time() * 1e3),
|
||||
"SvrMsgId": msgId,
|
||||
"ToUserName": toUserName}
|
||||
headers = {
|
||||
'ContentType': 'application/json; charset=UTF-8',
|
||||
'User-Agent' : config.USER_AGENT }
|
||||
r = self.s.post(url, headers=headers,
|
||||
data=json.dumps(data, ensure_ascii=False).encode('utf8'))
|
||||
return ReturnValue(rawResponse=r)
|
||||
106
lib/itchat/async_components/register.py
Normal file
106
lib/itchat/async_components/register.py
Normal file
@@ -0,0 +1,106 @@
|
||||
import logging, traceback, sys, threading
|
||||
try:
|
||||
import Queue
|
||||
except ImportError:
|
||||
import queue as Queue # type: ignore
|
||||
|
||||
from ..log import set_logging
|
||||
from ..utils import test_connect
|
||||
from ..storage import templates
|
||||
|
||||
logger = logging.getLogger('itchat')
|
||||
|
||||
def load_register(core):
|
||||
core.auto_login = auto_login
|
||||
core.configured_reply = configured_reply
|
||||
core.msg_register = msg_register
|
||||
core.run = run
|
||||
|
||||
async def auto_login(self, EventScanPayload=None,ScanStatus=None,event_stream=None,
|
||||
hotReload=True, statusStorageDir='itchat.pkl',
|
||||
enableCmdQR=False, picDir=None, qrCallback=None,
|
||||
loginCallback=None, exitCallback=None):
|
||||
if not test_connect():
|
||||
logger.info("You can't get access to internet or wechat domain, so exit.")
|
||||
sys.exit()
|
||||
self.useHotReload = hotReload
|
||||
self.hotReloadDir = statusStorageDir
|
||||
if hotReload:
|
||||
if await self.load_login_status(statusStorageDir,
|
||||
loginCallback=loginCallback, exitCallback=exitCallback):
|
||||
return
|
||||
await self.login(enableCmdQR=enableCmdQR, picDir=picDir, qrCallback=qrCallback, EventScanPayload=EventScanPayload, ScanStatus=ScanStatus, event_stream=event_stream,
|
||||
loginCallback=loginCallback, exitCallback=exitCallback)
|
||||
await self.dump_login_status(statusStorageDir)
|
||||
else:
|
||||
await self.login(enableCmdQR=enableCmdQR, picDir=picDir, qrCallback=qrCallback, EventScanPayload=EventScanPayload, ScanStatus=ScanStatus, event_stream=event_stream,
|
||||
loginCallback=loginCallback, exitCallback=exitCallback)
|
||||
|
||||
async def configured_reply(self, event_stream, payload, message_container):
|
||||
''' determine the type of message and reply if its method is defined
|
||||
however, I use a strange way to determine whether a msg is from massive platform
|
||||
I haven't found a better solution here
|
||||
The main problem I'm worrying about is the mismatching of new friends added on phone
|
||||
If you have any good idea, pleeeease report an issue. I will be more than grateful.
|
||||
'''
|
||||
try:
|
||||
msg = self.msgList.get(timeout=1)
|
||||
if 'MsgId' in msg.keys():
|
||||
message_container[msg['MsgId']] = msg
|
||||
except Queue.Empty:
|
||||
pass
|
||||
else:
|
||||
if isinstance(msg['User'], templates.User):
|
||||
replyFn = self.functionDict['FriendChat'].get(msg['Type'])
|
||||
elif isinstance(msg['User'], templates.MassivePlatform):
|
||||
replyFn = self.functionDict['MpChat'].get(msg['Type'])
|
||||
elif isinstance(msg['User'], templates.Chatroom):
|
||||
replyFn = self.functionDict['GroupChat'].get(msg['Type'])
|
||||
if replyFn is None:
|
||||
r = None
|
||||
else:
|
||||
try:
|
||||
r = await replyFn(msg)
|
||||
if r is not None:
|
||||
await self.send(r, msg.get('FromUserName'))
|
||||
except:
|
||||
logger.warning(traceback.format_exc())
|
||||
|
||||
def msg_register(self, msgType, isFriendChat=False, isGroupChat=False, isMpChat=False):
|
||||
''' a decorator constructor
|
||||
return a specific decorator based on information given '''
|
||||
if not (isinstance(msgType, list) or isinstance(msgType, tuple)):
|
||||
msgType = [msgType]
|
||||
def _msg_register(fn):
|
||||
for _msgType in msgType:
|
||||
if isFriendChat:
|
||||
self.functionDict['FriendChat'][_msgType] = fn
|
||||
if isGroupChat:
|
||||
self.functionDict['GroupChat'][_msgType] = fn
|
||||
if isMpChat:
|
||||
self.functionDict['MpChat'][_msgType] = fn
|
||||
if not any((isFriendChat, isGroupChat, isMpChat)):
|
||||
self.functionDict['FriendChat'][_msgType] = fn
|
||||
return fn
|
||||
return _msg_register
|
||||
|
||||
async def run(self, debug=False, blockThread=True):
|
||||
logger.info('Start auto replying.')
|
||||
if debug:
|
||||
set_logging(loggingLevel=logging.DEBUG)
|
||||
async def reply_fn():
|
||||
try:
|
||||
while self.alive:
|
||||
await self.configured_reply()
|
||||
except KeyboardInterrupt:
|
||||
if self.useHotReload:
|
||||
await self.dump_login_status()
|
||||
self.alive = False
|
||||
logger.debug('itchat received an ^C and exit.')
|
||||
logger.info('Bye~')
|
||||
if blockThread:
|
||||
await reply_fn()
|
||||
else:
|
||||
replyThread = threading.Thread(target=reply_fn)
|
||||
replyThread.setDaemon(True)
|
||||
replyThread.start()
|
||||
12
lib/itchat/components/__init__.py
Normal file
12
lib/itchat/components/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from .contact import load_contact
|
||||
from .hotreload import load_hotreload
|
||||
from .login import load_login
|
||||
from .messages import load_messages
|
||||
from .register import load_register
|
||||
|
||||
def load_components(core):
|
||||
load_contact(core)
|
||||
load_hotreload(core)
|
||||
load_login(core)
|
||||
load_messages(core)
|
||||
load_register(core)
|
||||
519
lib/itchat/components/contact.py
Normal file
519
lib/itchat/components/contact.py
Normal file
@@ -0,0 +1,519 @@
|
||||
import time
|
||||
import re
|
||||
import io
|
||||
import json
|
||||
import copy
|
||||
import logging
|
||||
|
||||
from .. import config, utils
|
||||
from ..returnvalues import ReturnValue
|
||||
from ..storage import contact_change
|
||||
from ..utils import update_info_dict
|
||||
|
||||
logger = logging.getLogger('itchat')
|
||||
|
||||
|
||||
def load_contact(core):
|
||||
core.update_chatroom = update_chatroom
|
||||
core.update_friend = update_friend
|
||||
core.get_contact = get_contact
|
||||
core.get_friends = get_friends
|
||||
core.get_chatrooms = get_chatrooms
|
||||
core.get_mps = get_mps
|
||||
core.set_alias = set_alias
|
||||
core.set_pinned = set_pinned
|
||||
core.accept_friend = accept_friend
|
||||
core.get_head_img = get_head_img
|
||||
core.create_chatroom = create_chatroom
|
||||
core.set_chatroom_name = set_chatroom_name
|
||||
core.delete_member_from_chatroom = delete_member_from_chatroom
|
||||
core.add_member_into_chatroom = add_member_into_chatroom
|
||||
|
||||
|
||||
def update_chatroom(self, userName, detailedMember=False):
|
||||
if not isinstance(userName, list):
|
||||
userName = [userName]
|
||||
url = '%s/webwxbatchgetcontact?type=ex&r=%s' % (
|
||||
self.loginInfo['url'], int(time.time()))
|
||||
headers = {
|
||||
'ContentType': 'application/json; charset=UTF-8',
|
||||
'User-Agent': config.USER_AGENT}
|
||||
data = {
|
||||
'BaseRequest': self.loginInfo['BaseRequest'],
|
||||
'Count': len(userName),
|
||||
'List': [{
|
||||
'UserName': u,
|
||||
'ChatRoomId': '', } for u in userName], }
|
||||
chatroomList = json.loads(self.s.post(url, data=json.dumps(data), headers=headers
|
||||
).content.decode('utf8', 'replace')).get('ContactList')
|
||||
if not chatroomList:
|
||||
return ReturnValue({'BaseResponse': {
|
||||
'ErrMsg': 'No chatroom found',
|
||||
'Ret': -1001, }})
|
||||
|
||||
if detailedMember:
|
||||
def get_detailed_member_info(encryChatroomId, memberList):
|
||||
url = '%s/webwxbatchgetcontact?type=ex&r=%s' % (
|
||||
self.loginInfo['url'], int(time.time()))
|
||||
headers = {
|
||||
'ContentType': 'application/json; charset=UTF-8',
|
||||
'User-Agent': config.USER_AGENT, }
|
||||
data = {
|
||||
'BaseRequest': self.loginInfo['BaseRequest'],
|
||||
'Count': len(memberList),
|
||||
'List': [{
|
||||
'UserName': member['UserName'],
|
||||
'EncryChatRoomId': encryChatroomId}
|
||||
for member in memberList], }
|
||||
return json.loads(self.s.post(url, data=json.dumps(data), headers=headers
|
||||
).content.decode('utf8', 'replace'))['ContactList']
|
||||
MAX_GET_NUMBER = 50
|
||||
for chatroom in chatroomList:
|
||||
totalMemberList = []
|
||||
for i in range(int(len(chatroom['MemberList']) / MAX_GET_NUMBER + 1)):
|
||||
memberList = chatroom['MemberList'][i *
|
||||
MAX_GET_NUMBER: (i+1)*MAX_GET_NUMBER]
|
||||
totalMemberList += get_detailed_member_info(
|
||||
chatroom['EncryChatRoomId'], memberList)
|
||||
chatroom['MemberList'] = totalMemberList
|
||||
|
||||
update_local_chatrooms(self, chatroomList)
|
||||
r = [self.storageClass.search_chatrooms(userName=c['UserName'])
|
||||
for c in chatroomList]
|
||||
return r if 1 < len(r) else r[0]
|
||||
|
||||
|
||||
def update_friend(self, userName):
|
||||
if not isinstance(userName, list):
|
||||
userName = [userName]
|
||||
url = '%s/webwxbatchgetcontact?type=ex&r=%s' % (
|
||||
self.loginInfo['url'], int(time.time()))
|
||||
headers = {
|
||||
'ContentType': 'application/json; charset=UTF-8',
|
||||
'User-Agent': config.USER_AGENT}
|
||||
data = {
|
||||
'BaseRequest': self.loginInfo['BaseRequest'],
|
||||
'Count': len(userName),
|
||||
'List': [{
|
||||
'UserName': u,
|
||||
'EncryChatRoomId': '', } for u in userName], }
|
||||
friendList = json.loads(self.s.post(url, data=json.dumps(data), headers=headers
|
||||
).content.decode('utf8', 'replace')).get('ContactList')
|
||||
|
||||
update_local_friends(self, friendList)
|
||||
r = [self.storageClass.search_friends(userName=f['UserName'])
|
||||
for f in friendList]
|
||||
return r if len(r) != 1 else r[0]
|
||||
|
||||
|
||||
@contact_change
|
||||
def update_local_chatrooms(core, l):
|
||||
'''
|
||||
get a list of chatrooms for updating local chatrooms
|
||||
return a list of given chatrooms with updated info
|
||||
'''
|
||||
for chatroom in l:
|
||||
# format new chatrooms
|
||||
utils.emoji_formatter(chatroom, 'NickName')
|
||||
for member in chatroom['MemberList']:
|
||||
if 'NickName' in member:
|
||||
utils.emoji_formatter(member, 'NickName')
|
||||
if 'DisplayName' in member:
|
||||
utils.emoji_formatter(member, 'DisplayName')
|
||||
if 'RemarkName' in member:
|
||||
utils.emoji_formatter(member, 'RemarkName')
|
||||
# update it to old chatrooms
|
||||
oldChatroom = utils.search_dict_list(
|
||||
core.chatroomList, 'UserName', chatroom['UserName'])
|
||||
if oldChatroom:
|
||||
update_info_dict(oldChatroom, chatroom)
|
||||
# - update other values
|
||||
memberList = chatroom.get('MemberList', [])
|
||||
oldMemberList = oldChatroom['MemberList']
|
||||
if memberList:
|
||||
for member in memberList:
|
||||
oldMember = utils.search_dict_list(
|
||||
oldMemberList, 'UserName', member['UserName'])
|
||||
if oldMember:
|
||||
update_info_dict(oldMember, member)
|
||||
else:
|
||||
oldMemberList.append(member)
|
||||
else:
|
||||
core.chatroomList.append(chatroom)
|
||||
oldChatroom = utils.search_dict_list(
|
||||
core.chatroomList, 'UserName', chatroom['UserName'])
|
||||
# delete useless members
|
||||
if len(chatroom['MemberList']) != len(oldChatroom['MemberList']) and \
|
||||
chatroom['MemberList']:
|
||||
existsUserNames = [member['UserName']
|
||||
for member in chatroom['MemberList']]
|
||||
delList = []
|
||||
for i, member in enumerate(oldChatroom['MemberList']):
|
||||
if member['UserName'] not in existsUserNames:
|
||||
delList.append(i)
|
||||
delList.sort(reverse=True)
|
||||
for i in delList:
|
||||
del oldChatroom['MemberList'][i]
|
||||
# - update OwnerUin
|
||||
if oldChatroom.get('ChatRoomOwner') and oldChatroom.get('MemberList'):
|
||||
owner = utils.search_dict_list(oldChatroom['MemberList'],
|
||||
'UserName', oldChatroom['ChatRoomOwner'])
|
||||
oldChatroom['OwnerUin'] = (owner or {}).get('Uin', 0)
|
||||
# - update IsAdmin
|
||||
if 'OwnerUin' in oldChatroom and oldChatroom['OwnerUin'] != 0:
|
||||
oldChatroom['IsAdmin'] = \
|
||||
oldChatroom['OwnerUin'] == int(core.loginInfo['wxuin'])
|
||||
else:
|
||||
oldChatroom['IsAdmin'] = None
|
||||
# - update Self
|
||||
newSelf = utils.search_dict_list(oldChatroom['MemberList'],
|
||||
'UserName', core.storageClass.userName)
|
||||
oldChatroom['Self'] = newSelf or copy.deepcopy(core.loginInfo['User'])
|
||||
return {
|
||||
'Type': 'System',
|
||||
'Text': [chatroom['UserName'] for chatroom in l],
|
||||
'SystemInfo': 'chatrooms',
|
||||
'FromUserName': core.storageClass.userName,
|
||||
'ToUserName': core.storageClass.userName, }
|
||||
|
||||
|
||||
@contact_change
|
||||
def update_local_friends(core, l):
|
||||
'''
|
||||
get a list of friends or mps for updating local contact
|
||||
'''
|
||||
fullList = core.memberList + core.mpList
|
||||
for friend in l:
|
||||
if 'NickName' in friend:
|
||||
utils.emoji_formatter(friend, 'NickName')
|
||||
if 'DisplayName' in friend:
|
||||
utils.emoji_formatter(friend, 'DisplayName')
|
||||
if 'RemarkName' in friend:
|
||||
utils.emoji_formatter(friend, 'RemarkName')
|
||||
oldInfoDict = utils.search_dict_list(
|
||||
fullList, 'UserName', friend['UserName'])
|
||||
if oldInfoDict is None:
|
||||
oldInfoDict = copy.deepcopy(friend)
|
||||
if oldInfoDict['VerifyFlag'] & 8 == 0:
|
||||
core.memberList.append(oldInfoDict)
|
||||
else:
|
||||
core.mpList.append(oldInfoDict)
|
||||
else:
|
||||
update_info_dict(oldInfoDict, friend)
|
||||
|
||||
|
||||
@contact_change
|
||||
def update_local_uin(core, msg):
|
||||
'''
|
||||
content contains uins and StatusNotifyUserName contains username
|
||||
they are in same order, so what I do is to pair them together
|
||||
|
||||
I caught an exception in this method while not knowing why
|
||||
but don't worry, it won't cause any problem
|
||||
'''
|
||||
uins = re.search('<username>([^<]*?)<', msg['Content'])
|
||||
usernameChangedList = []
|
||||
r = {
|
||||
'Type': 'System',
|
||||
'Text': usernameChangedList,
|
||||
'SystemInfo': 'uins', }
|
||||
if uins:
|
||||
uins = uins.group(1).split(',')
|
||||
usernames = msg['StatusNotifyUserName'].split(',')
|
||||
if 0 < len(uins) == len(usernames):
|
||||
for uin, username in zip(uins, usernames):
|
||||
if not '@' in username:
|
||||
continue
|
||||
fullContact = core.memberList + core.chatroomList + core.mpList
|
||||
userDicts = utils.search_dict_list(fullContact,
|
||||
'UserName', username)
|
||||
if userDicts:
|
||||
if userDicts.get('Uin', 0) == 0:
|
||||
userDicts['Uin'] = uin
|
||||
usernameChangedList.append(username)
|
||||
logger.debug('Uin fetched: %s, %s' % (username, uin))
|
||||
else:
|
||||
if userDicts['Uin'] != uin:
|
||||
logger.debug('Uin changed: %s, %s' % (
|
||||
userDicts['Uin'], uin))
|
||||
else:
|
||||
if '@@' in username:
|
||||
core.storageClass.updateLock.release()
|
||||
update_chatroom(core, username)
|
||||
core.storageClass.updateLock.acquire()
|
||||
newChatroomDict = utils.search_dict_list(
|
||||
core.chatroomList, 'UserName', username)
|
||||
if newChatroomDict is None:
|
||||
newChatroomDict = utils.struct_friend_info({
|
||||
'UserName': username,
|
||||
'Uin': uin,
|
||||
'Self': copy.deepcopy(core.loginInfo['User'])})
|
||||
core.chatroomList.append(newChatroomDict)
|
||||
else:
|
||||
newChatroomDict['Uin'] = uin
|
||||
elif '@' in username:
|
||||
core.storageClass.updateLock.release()
|
||||
update_friend(core, username)
|
||||
core.storageClass.updateLock.acquire()
|
||||
newFriendDict = utils.search_dict_list(
|
||||
core.memberList, 'UserName', username)
|
||||
if newFriendDict is None:
|
||||
newFriendDict = utils.struct_friend_info({
|
||||
'UserName': username,
|
||||
'Uin': uin, })
|
||||
core.memberList.append(newFriendDict)
|
||||
else:
|
||||
newFriendDict['Uin'] = uin
|
||||
usernameChangedList.append(username)
|
||||
logger.debug('Uin fetched: %s, %s' % (username, uin))
|
||||
else:
|
||||
logger.debug('Wrong length of uins & usernames: %s, %s' % (
|
||||
len(uins), len(usernames)))
|
||||
else:
|
||||
logger.debug('No uins in 51 message')
|
||||
logger.debug(msg['Content'])
|
||||
return r
|
||||
|
||||
|
||||
def get_contact(self, update=False):
|
||||
if not update:
|
||||
return utils.contact_deep_copy(self, self.chatroomList)
|
||||
|
||||
def _get_contact(seq=0):
|
||||
url = '%s/webwxgetcontact?r=%s&seq=%s&skey=%s' % (self.loginInfo['url'],
|
||||
int(time.time()), seq, self.loginInfo['skey'])
|
||||
headers = {
|
||||
'ContentType': 'application/json; charset=UTF-8',
|
||||
'User-Agent': config.USER_AGENT, }
|
||||
try:
|
||||
r = self.s.get(url, headers=headers)
|
||||
except:
|
||||
logger.info(
|
||||
'Failed to fetch contact, that may because of the amount of your chatrooms')
|
||||
for chatroom in self.get_chatrooms():
|
||||
self.update_chatroom(chatroom['UserName'], detailedMember=True)
|
||||
return 0, []
|
||||
j = json.loads(r.content.decode('utf-8', 'replace'))
|
||||
return j.get('Seq', 0), j.get('MemberList')
|
||||
seq, memberList = 0, []
|
||||
while 1:
|
||||
seq, batchMemberList = _get_contact(seq)
|
||||
memberList.extend(batchMemberList)
|
||||
if seq == 0:
|
||||
break
|
||||
chatroomList, otherList = [], []
|
||||
for m in memberList:
|
||||
if m['Sex'] != 0:
|
||||
otherList.append(m)
|
||||
elif '@@' in m['UserName']:
|
||||
chatroomList.append(m)
|
||||
elif '@' in m['UserName']:
|
||||
# mp will be dealt in update_local_friends as well
|
||||
otherList.append(m)
|
||||
if chatroomList:
|
||||
update_local_chatrooms(self, chatroomList)
|
||||
if otherList:
|
||||
update_local_friends(self, otherList)
|
||||
return utils.contact_deep_copy(self, chatroomList)
|
||||
|
||||
|
||||
def get_friends(self, update=False):
|
||||
if update:
|
||||
self.get_contact(update=True)
|
||||
return utils.contact_deep_copy(self, self.memberList)
|
||||
|
||||
|
||||
def get_chatrooms(self, update=False, contactOnly=False):
|
||||
if contactOnly:
|
||||
return self.get_contact(update=True)
|
||||
else:
|
||||
if update:
|
||||
self.get_contact(True)
|
||||
return utils.contact_deep_copy(self, self.chatroomList)
|
||||
|
||||
|
||||
def get_mps(self, update=False):
|
||||
if update:
|
||||
self.get_contact(update=True)
|
||||
return utils.contact_deep_copy(self, self.mpList)
|
||||
|
||||
|
||||
def set_alias(self, userName, alias):
|
||||
oldFriendInfo = utils.search_dict_list(
|
||||
self.memberList, 'UserName', userName)
|
||||
if oldFriendInfo is None:
|
||||
return ReturnValue({'BaseResponse': {
|
||||
'Ret': -1001, }})
|
||||
url = '%s/webwxoplog?lang=%s&pass_ticket=%s' % (
|
||||
self.loginInfo['url'], 'zh_CN', self.loginInfo['pass_ticket'])
|
||||
data = {
|
||||
'UserName': userName,
|
||||
'CmdId': 2,
|
||||
'RemarkName': alias,
|
||||
'BaseRequest': self.loginInfo['BaseRequest'], }
|
||||
headers = {'User-Agent': config.USER_AGENT}
|
||||
r = self.s.post(url, json.dumps(data, ensure_ascii=False).encode('utf8'),
|
||||
headers=headers)
|
||||
r = ReturnValue(rawResponse=r)
|
||||
if r:
|
||||
oldFriendInfo['RemarkName'] = alias
|
||||
return r
|
||||
|
||||
|
||||
def set_pinned(self, userName, isPinned=True):
|
||||
url = '%s/webwxoplog?pass_ticket=%s' % (
|
||||
self.loginInfo['url'], self.loginInfo['pass_ticket'])
|
||||
data = {
|
||||
'UserName': userName,
|
||||
'CmdId': 3,
|
||||
'OP': int(isPinned),
|
||||
'BaseRequest': self.loginInfo['BaseRequest'], }
|
||||
headers = {'User-Agent': config.USER_AGENT}
|
||||
r = self.s.post(url, json=data, headers=headers)
|
||||
return ReturnValue(rawResponse=r)
|
||||
|
||||
|
||||
def accept_friend(self, userName, v4='', autoUpdate=True):
|
||||
url = f"{self.loginInfo['url']}/webwxverifyuser?r={int(time.time())}&pass_ticket={self.loginInfo['pass_ticket']}"
|
||||
data = {
|
||||
'BaseRequest': self.loginInfo['BaseRequest'],
|
||||
'Opcode': 3, # 3
|
||||
'VerifyUserListSize': 1,
|
||||
'VerifyUserList': [{
|
||||
'Value': userName,
|
||||
'VerifyUserTicket': v4, }],
|
||||
'VerifyContent': '',
|
||||
'SceneListCount': 1,
|
||||
'SceneList': [33],
|
||||
'skey': self.loginInfo['skey'], }
|
||||
headers = {
|
||||
'ContentType': 'application/json; charset=UTF-8',
|
||||
'User-Agent': config.USER_AGENT}
|
||||
r = self.s.post(url, headers=headers,
|
||||
data=json.dumps(data, ensure_ascii=False).encode('utf8', 'replace'))
|
||||
if autoUpdate:
|
||||
self.update_friend(userName)
|
||||
return ReturnValue(rawResponse=r)
|
||||
|
||||
|
||||
def get_head_img(self, userName=None, chatroomUserName=None, picDir=None):
|
||||
''' get head image
|
||||
* if you want to get chatroom header: only set chatroomUserName
|
||||
* if you want to get friend header: only set userName
|
||||
* if you want to get chatroom member header: set both
|
||||
'''
|
||||
params = {
|
||||
'userName': userName or chatroomUserName or self.storageClass.userName,
|
||||
'skey': self.loginInfo['skey'],
|
||||
'type': 'big', }
|
||||
url = '%s/webwxgeticon' % self.loginInfo['url']
|
||||
if chatroomUserName is None:
|
||||
infoDict = self.storageClass.search_friends(userName=userName)
|
||||
if infoDict is None:
|
||||
return ReturnValue({'BaseResponse': {
|
||||
'ErrMsg': 'No friend found',
|
||||
'Ret': -1001, }})
|
||||
else:
|
||||
if userName is None:
|
||||
url = '%s/webwxgetheadimg' % self.loginInfo['url']
|
||||
else:
|
||||
chatroom = self.storageClass.search_chatrooms(
|
||||
userName=chatroomUserName)
|
||||
if chatroomUserName is None:
|
||||
return ReturnValue({'BaseResponse': {
|
||||
'ErrMsg': 'No chatroom found',
|
||||
'Ret': -1001, }})
|
||||
if 'EncryChatRoomId' in chatroom:
|
||||
params['chatroomid'] = chatroom['EncryChatRoomId']
|
||||
params['chatroomid'] = params.get(
|
||||
'chatroomid') or chatroom['UserName']
|
||||
headers = {'User-Agent': config.USER_AGENT}
|
||||
r = self.s.get(url, params=params, stream=True, headers=headers)
|
||||
tempStorage = io.BytesIO()
|
||||
for block in r.iter_content(1024):
|
||||
tempStorage.write(block)
|
||||
if picDir is None:
|
||||
return tempStorage.getvalue()
|
||||
with open(picDir, 'wb') as f:
|
||||
f.write(tempStorage.getvalue())
|
||||
tempStorage.seek(0)
|
||||
return ReturnValue({'BaseResponse': {
|
||||
'ErrMsg': 'Successfully downloaded',
|
||||
'Ret': 0, },
|
||||
'PostFix': utils.get_image_postfix(tempStorage.read(20)), })
|
||||
|
||||
|
||||
def create_chatroom(self, memberList, topic=''):
|
||||
url = '%s/webwxcreatechatroom?pass_ticket=%s&r=%s' % (
|
||||
self.loginInfo['url'], self.loginInfo['pass_ticket'], int(time.time()))
|
||||
data = {
|
||||
'BaseRequest': self.loginInfo['BaseRequest'],
|
||||
'MemberCount': len(memberList.split(',')),
|
||||
'MemberList': [{'UserName': member} for member in memberList.split(',')],
|
||||
'Topic': topic, }
|
||||
headers = {
|
||||
'content-type': 'application/json; charset=UTF-8',
|
||||
'User-Agent': config.USER_AGENT}
|
||||
r = self.s.post(url, headers=headers,
|
||||
data=json.dumps(data, ensure_ascii=False).encode('utf8', 'ignore'))
|
||||
return ReturnValue(rawResponse=r)
|
||||
|
||||
|
||||
def set_chatroom_name(self, chatroomUserName, name):
|
||||
url = '%s/webwxupdatechatroom?fun=modtopic&pass_ticket=%s' % (
|
||||
self.loginInfo['url'], self.loginInfo['pass_ticket'])
|
||||
data = {
|
||||
'BaseRequest': self.loginInfo['BaseRequest'],
|
||||
'ChatRoomName': chatroomUserName,
|
||||
'NewTopic': name, }
|
||||
headers = {
|
||||
'content-type': 'application/json; charset=UTF-8',
|
||||
'User-Agent': config.USER_AGENT}
|
||||
r = self.s.post(url, headers=headers,
|
||||
data=json.dumps(data, ensure_ascii=False).encode('utf8', 'ignore'))
|
||||
return ReturnValue(rawResponse=r)
|
||||
|
||||
|
||||
def delete_member_from_chatroom(self, chatroomUserName, memberList):
|
||||
url = '%s/webwxupdatechatroom?fun=delmember&pass_ticket=%s' % (
|
||||
self.loginInfo['url'], self.loginInfo['pass_ticket'])
|
||||
data = {
|
||||
'BaseRequest': self.loginInfo['BaseRequest'],
|
||||
'ChatRoomName': chatroomUserName,
|
||||
'DelMemberList': ','.join([member['UserName'] for member in memberList]), }
|
||||
headers = {
|
||||
'content-type': 'application/json; charset=UTF-8',
|
||||
'User-Agent': config.USER_AGENT}
|
||||
r = self.s.post(url, data=json.dumps(data), headers=headers)
|
||||
return ReturnValue(rawResponse=r)
|
||||
|
||||
|
||||
def add_member_into_chatroom(self, chatroomUserName, memberList,
|
||||
useInvitation=False):
|
||||
''' add or invite member into chatroom
|
||||
* there are two ways to get members into chatroom: invite or directly add
|
||||
* but for chatrooms with more than 40 users, you can only use invite
|
||||
* but don't worry we will auto-force userInvitation for you when necessary
|
||||
'''
|
||||
if not useInvitation:
|
||||
chatroom = self.storageClass.search_chatrooms(
|
||||
userName=chatroomUserName)
|
||||
if not chatroom:
|
||||
chatroom = self.update_chatroom(chatroomUserName)
|
||||
if len(chatroom['MemberList']) > self.loginInfo['InviteStartCount']:
|
||||
useInvitation = True
|
||||
if useInvitation:
|
||||
fun, memberKeyName = 'invitemember', 'InviteMemberList'
|
||||
else:
|
||||
fun, memberKeyName = 'addmember', 'AddMemberList'
|
||||
url = '%s/webwxupdatechatroom?fun=%s&pass_ticket=%s' % (
|
||||
self.loginInfo['url'], fun, self.loginInfo['pass_ticket'])
|
||||
params = {
|
||||
'BaseRequest': self.loginInfo['BaseRequest'],
|
||||
'ChatRoomName': chatroomUserName,
|
||||
memberKeyName: memberList, }
|
||||
headers = {
|
||||
'content-type': 'application/json; charset=UTF-8',
|
||||
'User-Agent': config.USER_AGENT}
|
||||
r = self.s.post(url, data=json.dumps(params), headers=headers)
|
||||
return ReturnValue(rawResponse=r)
|
||||
102
lib/itchat/components/hotreload.py
Normal file
102
lib/itchat/components/hotreload.py
Normal file
@@ -0,0 +1,102 @@
|
||||
import pickle, os
|
||||
import logging
|
||||
|
||||
import requests
|
||||
|
||||
from ..config import VERSION
|
||||
from ..returnvalues import ReturnValue
|
||||
from ..storage import templates
|
||||
from .contact import update_local_chatrooms, update_local_friends
|
||||
from .messages import produce_msg
|
||||
|
||||
logger = logging.getLogger('itchat')
|
||||
|
||||
def load_hotreload(core):
|
||||
core.dump_login_status = dump_login_status
|
||||
core.load_login_status = load_login_status
|
||||
|
||||
def dump_login_status(self, fileDir=None):
|
||||
fileDir = fileDir or self.hotReloadDir
|
||||
try:
|
||||
with open(fileDir, 'w') as f:
|
||||
f.write('itchat - DELETE THIS')
|
||||
os.remove(fileDir)
|
||||
except:
|
||||
raise Exception('Incorrect fileDir')
|
||||
status = {
|
||||
'version' : VERSION,
|
||||
'loginInfo' : self.loginInfo,
|
||||
'cookies' : self.s.cookies.get_dict(),
|
||||
'storage' : self.storageClass.dumps()}
|
||||
with open(fileDir, 'wb') as f:
|
||||
pickle.dump(status, f)
|
||||
logger.debug('Dump login status for hot reload successfully.')
|
||||
|
||||
def load_login_status(self, fileDir,
|
||||
loginCallback=None, exitCallback=None):
|
||||
try:
|
||||
with open(fileDir, 'rb') as f:
|
||||
j = pickle.load(f)
|
||||
except Exception as e:
|
||||
logger.debug('No such file, loading login status failed.')
|
||||
return ReturnValue({'BaseResponse': {
|
||||
'ErrMsg': 'No such file, loading login status failed.',
|
||||
'Ret': -1002, }})
|
||||
|
||||
if j.get('version', '') != VERSION:
|
||||
logger.debug(('you have updated itchat from %s to %s, ' +
|
||||
'so cached status is ignored') % (
|
||||
j.get('version', 'old version'), VERSION))
|
||||
return ReturnValue({'BaseResponse': {
|
||||
'ErrMsg': 'cached status ignored because of version',
|
||||
'Ret': -1005, }})
|
||||
self.loginInfo = j['loginInfo']
|
||||
self.loginInfo['User'] = templates.User(self.loginInfo['User'])
|
||||
self.loginInfo['User'].core = self
|
||||
self.s.cookies = requests.utils.cookiejar_from_dict(j['cookies'])
|
||||
self.storageClass.loads(j['storage'])
|
||||
try:
|
||||
msgList, contactList = self.get_msg()
|
||||
except:
|
||||
msgList = contactList = None
|
||||
if (msgList or contactList) is None:
|
||||
self.logout()
|
||||
load_last_login_status(self.s, j['cookies'])
|
||||
logger.debug('server refused, loading login status failed.')
|
||||
return ReturnValue({'BaseResponse': {
|
||||
'ErrMsg': 'server refused, loading login status failed.',
|
||||
'Ret': -1003, }})
|
||||
else:
|
||||
if contactList:
|
||||
for contact in contactList:
|
||||
if '@@' in contact['UserName']:
|
||||
update_local_chatrooms(self, [contact])
|
||||
else:
|
||||
update_local_friends(self, [contact])
|
||||
if msgList:
|
||||
msgList = produce_msg(self, msgList)
|
||||
for msg in msgList: self.msgList.put(msg)
|
||||
self.start_receiving(exitCallback)
|
||||
logger.debug('loading login status succeeded.')
|
||||
if hasattr(loginCallback, '__call__'):
|
||||
loginCallback()
|
||||
return ReturnValue({'BaseResponse': {
|
||||
'ErrMsg': 'loading login status succeeded.',
|
||||
'Ret': 0, }})
|
||||
|
||||
def load_last_login_status(session, cookiesDict):
|
||||
try:
|
||||
session.cookies = requests.utils.cookiejar_from_dict({
|
||||
'webwxuvid': cookiesDict['webwxuvid'],
|
||||
'webwx_auth_ticket': cookiesDict['webwx_auth_ticket'],
|
||||
'login_frequency': '2',
|
||||
'last_wxuin': cookiesDict['wxuin'],
|
||||
'wxloadtime': cookiesDict['wxloadtime'] + '_expired',
|
||||
'wxpluginkey': cookiesDict['wxloadtime'],
|
||||
'wxuin': cookiesDict['wxuin'],
|
||||
'mm_lang': 'zh_CN',
|
||||
'MM_WX_NOTIFY_STATE': '1',
|
||||
'MM_WX_SOUND_STATE': '1', })
|
||||
except:
|
||||
logger.info('Load status for push login failed, we may have experienced a cookies change.')
|
||||
logger.info('If you are using the newest version of itchat, you may report a bug.')
|
||||
411
lib/itchat/components/login.py
Normal file
411
lib/itchat/components/login.py
Normal file
@@ -0,0 +1,411 @@
|
||||
import os
|
||||
import time
|
||||
import re
|
||||
import io
|
||||
import threading
|
||||
import json
|
||||
import xml.dom.minidom
|
||||
import random
|
||||
import traceback
|
||||
import logging
|
||||
try:
|
||||
from httplib import BadStatusLine
|
||||
except ImportError:
|
||||
from http.client import BadStatusLine
|
||||
|
||||
import requests
|
||||
from pyqrcode import QRCode
|
||||
|
||||
from .. import config, utils
|
||||
from ..returnvalues import ReturnValue
|
||||
from ..storage.templates import wrap_user_dict
|
||||
from .contact import update_local_chatrooms, update_local_friends
|
||||
from .messages import produce_msg
|
||||
|
||||
logger = logging.getLogger('itchat')
|
||||
|
||||
|
||||
def load_login(core):
|
||||
core.login = login
|
||||
core.get_QRuuid = get_QRuuid
|
||||
core.get_QR = get_QR
|
||||
core.check_login = check_login
|
||||
core.web_init = web_init
|
||||
core.show_mobile_login = show_mobile_login
|
||||
core.start_receiving = start_receiving
|
||||
core.get_msg = get_msg
|
||||
core.logout = logout
|
||||
|
||||
|
||||
def login(self, enableCmdQR=False, picDir=None, qrCallback=None,
|
||||
loginCallback=None, exitCallback=None):
|
||||
if self.alive or self.isLogging:
|
||||
logger.warning('itchat has already logged in.')
|
||||
return
|
||||
self.isLogging = True
|
||||
while self.isLogging:
|
||||
uuid = push_login(self)
|
||||
if uuid:
|
||||
qrStorage = io.BytesIO()
|
||||
else:
|
||||
logger.info('Getting uuid of QR code.')
|
||||
while not self.get_QRuuid():
|
||||
time.sleep(1)
|
||||
logger.info('Downloading QR code.')
|
||||
qrStorage = self.get_QR(enableCmdQR=enableCmdQR,
|
||||
picDir=picDir, qrCallback=qrCallback)
|
||||
# logger.info('Please scan the QR code to log in.')
|
||||
isLoggedIn = False
|
||||
while not isLoggedIn:
|
||||
status = self.check_login()
|
||||
if hasattr(qrCallback, '__call__'):
|
||||
qrCallback(uuid=self.uuid, status=status,
|
||||
qrcode=qrStorage.getvalue())
|
||||
if status == '200':
|
||||
isLoggedIn = True
|
||||
elif status == '201':
|
||||
if isLoggedIn is not None:
|
||||
logger.info('Please press confirm on your phone.')
|
||||
isLoggedIn = None
|
||||
time.sleep(7)
|
||||
time.sleep(0.5)
|
||||
elif status != '408':
|
||||
break
|
||||
if isLoggedIn:
|
||||
break
|
||||
elif self.isLogging:
|
||||
logger.info('Log in time out, reloading QR code.')
|
||||
else:
|
||||
return # log in process is stopped by user
|
||||
logger.info('Loading the contact, this may take a little while.')
|
||||
self.web_init()
|
||||
self.show_mobile_login()
|
||||
self.get_contact(True)
|
||||
if hasattr(loginCallback, '__call__'):
|
||||
r = loginCallback()
|
||||
else:
|
||||
utils.clear_screen()
|
||||
if os.path.exists(picDir or config.DEFAULT_QR):
|
||||
os.remove(picDir or config.DEFAULT_QR)
|
||||
logger.info('Login successfully as %s' % self.storageClass.nickName)
|
||||
self.start_receiving(exitCallback)
|
||||
self.isLogging = False
|
||||
|
||||
|
||||
def push_login(core):
|
||||
cookiesDict = core.s.cookies.get_dict()
|
||||
if 'wxuin' in cookiesDict:
|
||||
url = '%s/cgi-bin/mmwebwx-bin/webwxpushloginurl?uin=%s' % (
|
||||
config.BASE_URL, cookiesDict['wxuin'])
|
||||
headers = {'User-Agent': config.USER_AGENT}
|
||||
r = core.s.get(url, headers=headers).json()
|
||||
if 'uuid' in r and r.get('ret') in (0, '0'):
|
||||
core.uuid = r['uuid']
|
||||
return r['uuid']
|
||||
return False
|
||||
|
||||
|
||||
def get_QRuuid(self):
|
||||
url = '%s/jslogin' % config.BASE_URL
|
||||
params = {
|
||||
'appid': 'wx782c26e4c19acffb',
|
||||
'fun': 'new',
|
||||
'redirect_uri': 'https://wx.qq.com/cgi-bin/mmwebwx-bin/webwxnewloginpage?mod=desktop',
|
||||
'lang': 'zh_CN'}
|
||||
headers = {'User-Agent': config.USER_AGENT}
|
||||
r = self.s.get(url, params=params, headers=headers)
|
||||
regx = r'window.QRLogin.code = (\d+); window.QRLogin.uuid = "(\S+?)";'
|
||||
data = re.search(regx, r.text)
|
||||
if data and data.group(1) == '200':
|
||||
self.uuid = data.group(2)
|
||||
return self.uuid
|
||||
|
||||
|
||||
def get_QR(self, uuid=None, enableCmdQR=False, picDir=None, qrCallback=None):
|
||||
uuid = uuid or self.uuid
|
||||
picDir = picDir or config.DEFAULT_QR
|
||||
qrStorage = io.BytesIO()
|
||||
qrCode = QRCode('https://login.weixin.qq.com/l/' + uuid)
|
||||
qrCode.png(qrStorage, scale=10)
|
||||
if hasattr(qrCallback, '__call__'):
|
||||
qrCallback(uuid=uuid, status='0', qrcode=qrStorage.getvalue())
|
||||
else:
|
||||
with open(picDir, 'wb') as f:
|
||||
f.write(qrStorage.getvalue())
|
||||
if enableCmdQR:
|
||||
utils.print_cmd_qr(qrCode.text(1), enableCmdQR=enableCmdQR)
|
||||
else:
|
||||
utils.print_qr(picDir)
|
||||
return qrStorage
|
||||
|
||||
|
||||
def check_login(self, uuid=None):
|
||||
uuid = uuid or self.uuid
|
||||
url = '%s/cgi-bin/mmwebwx-bin/login' % config.BASE_URL
|
||||
localTime = int(time.time())
|
||||
params = 'loginicon=true&uuid=%s&tip=1&r=%s&_=%s' % (
|
||||
uuid, int(-localTime / 1579), localTime)
|
||||
headers = {'User-Agent': config.USER_AGENT}
|
||||
r = self.s.get(url, params=params, headers=headers)
|
||||
regx = r'window.code=(\d+)'
|
||||
data = re.search(regx, r.text)
|
||||
if data and data.group(1) == '200':
|
||||
if process_login_info(self, r.text):
|
||||
return '200'
|
||||
else:
|
||||
return '400'
|
||||
elif data:
|
||||
return data.group(1)
|
||||
else:
|
||||
return '400'
|
||||
|
||||
|
||||
def process_login_info(core, loginContent):
|
||||
''' when finish login (scanning qrcode)
|
||||
* syncUrl and fileUploadingUrl will be fetched
|
||||
* deviceid and msgid will be generated
|
||||
* skey, wxsid, wxuin, pass_ticket will be fetched
|
||||
'''
|
||||
regx = r'window.redirect_uri="(\S+)";'
|
||||
core.loginInfo['url'] = re.search(regx, loginContent).group(1)
|
||||
headers = {'User-Agent': config.USER_AGENT,
|
||||
'client-version': config.UOS_PATCH_CLIENT_VERSION,
|
||||
'extspam': config.UOS_PATCH_EXTSPAM,
|
||||
'referer': 'https://wx.qq.com/?&lang=zh_CN&target=t'
|
||||
}
|
||||
r = core.s.get(core.loginInfo['url'],
|
||||
headers=headers, allow_redirects=False)
|
||||
core.loginInfo['url'] = core.loginInfo['url'][:core.loginInfo['url'].rfind(
|
||||
'/')]
|
||||
for indexUrl, detailedUrl in (
|
||||
("wx2.qq.com", ("file.wx2.qq.com", "webpush.wx2.qq.com")),
|
||||
("wx8.qq.com", ("file.wx8.qq.com", "webpush.wx8.qq.com")),
|
||||
("qq.com", ("file.wx.qq.com", "webpush.wx.qq.com")),
|
||||
("web2.wechat.com", ("file.web2.wechat.com", "webpush.web2.wechat.com")),
|
||||
("wechat.com", ("file.web.wechat.com", "webpush.web.wechat.com"))):
|
||||
fileUrl, syncUrl = ['https://%s/cgi-bin/mmwebwx-bin' %
|
||||
url for url in detailedUrl]
|
||||
if indexUrl in core.loginInfo['url']:
|
||||
core.loginInfo['fileUrl'], core.loginInfo['syncUrl'] = \
|
||||
fileUrl, syncUrl
|
||||
break
|
||||
else:
|
||||
core.loginInfo['fileUrl'] = core.loginInfo['syncUrl'] = core.loginInfo['url']
|
||||
core.loginInfo['deviceid'] = 'e' + repr(random.random())[2:17]
|
||||
core.loginInfo['logintime'] = int(time.time() * 1e3)
|
||||
core.loginInfo['BaseRequest'] = {}
|
||||
cookies = core.s.cookies.get_dict()
|
||||
skey = re.findall('<skey>(.*?)</skey>', r.text, re.S)[0]
|
||||
pass_ticket = re.findall(
|
||||
'<pass_ticket>(.*?)</pass_ticket>', r.text, re.S)[0]
|
||||
core.loginInfo['skey'] = core.loginInfo['BaseRequest']['Skey'] = skey
|
||||
core.loginInfo['wxsid'] = core.loginInfo['BaseRequest']['Sid'] = cookies["wxsid"]
|
||||
core.loginInfo['wxuin'] = core.loginInfo['BaseRequest']['Uin'] = cookies["wxuin"]
|
||||
core.loginInfo['pass_ticket'] = pass_ticket
|
||||
# A question : why pass_ticket == DeviceID ?
|
||||
# deviceID is only a randomly generated number
|
||||
|
||||
# UOS PATCH By luvletter2333, Sun Feb 28 10:00 PM
|
||||
# for node in xml.dom.minidom.parseString(r.text).documentElement.childNodes:
|
||||
# if node.nodeName == 'skey':
|
||||
# core.loginInfo['skey'] = core.loginInfo['BaseRequest']['Skey'] = node.childNodes[0].data
|
||||
# elif node.nodeName == 'wxsid':
|
||||
# core.loginInfo['wxsid'] = core.loginInfo['BaseRequest']['Sid'] = node.childNodes[0].data
|
||||
# elif node.nodeName == 'wxuin':
|
||||
# core.loginInfo['wxuin'] = core.loginInfo['BaseRequest']['Uin'] = node.childNodes[0].data
|
||||
# elif node.nodeName == 'pass_ticket':
|
||||
# core.loginInfo['pass_ticket'] = core.loginInfo['BaseRequest']['DeviceID'] = node.childNodes[0].data
|
||||
if not all([key in core.loginInfo for key in ('skey', 'wxsid', 'wxuin', 'pass_ticket')]):
|
||||
logger.error(
|
||||
'Your wechat account may be LIMITED to log in WEB wechat, error info:\n%s' % r.text)
|
||||
core.isLogging = False
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def web_init(self):
|
||||
url = '%s/webwxinit' % self.loginInfo['url']
|
||||
params = {
|
||||
'r': int(-time.time() / 1579),
|
||||
'pass_ticket': self.loginInfo['pass_ticket'], }
|
||||
data = {'BaseRequest': self.loginInfo['BaseRequest'], }
|
||||
headers = {
|
||||
'ContentType': 'application/json; charset=UTF-8',
|
||||
'User-Agent': config.USER_AGENT, }
|
||||
r = self.s.post(url, params=params, data=json.dumps(data), headers=headers)
|
||||
dic = json.loads(r.content.decode('utf-8', 'replace'))
|
||||
# deal with login info
|
||||
utils.emoji_formatter(dic['User'], 'NickName')
|
||||
self.loginInfo['InviteStartCount'] = int(dic['InviteStartCount'])
|
||||
self.loginInfo['User'] = wrap_user_dict(
|
||||
utils.struct_friend_info(dic['User']))
|
||||
self.memberList.append(self.loginInfo['User'])
|
||||
self.loginInfo['SyncKey'] = dic['SyncKey']
|
||||
self.loginInfo['synckey'] = '|'.join(['%s_%s' % (item['Key'], item['Val'])
|
||||
for item in dic['SyncKey']['List']])
|
||||
self.storageClass.userName = dic['User']['UserName']
|
||||
self.storageClass.nickName = dic['User']['NickName']
|
||||
# deal with contact list returned when init
|
||||
contactList = dic.get('ContactList', [])
|
||||
chatroomList, otherList = [], []
|
||||
for m in contactList:
|
||||
if m['Sex'] != 0:
|
||||
otherList.append(m)
|
||||
elif '@@' in m['UserName']:
|
||||
m['MemberList'] = [] # don't let dirty info pollute the list
|
||||
chatroomList.append(m)
|
||||
elif '@' in m['UserName']:
|
||||
# mp will be dealt in update_local_friends as well
|
||||
otherList.append(m)
|
||||
if chatroomList:
|
||||
update_local_chatrooms(self, chatroomList)
|
||||
if otherList:
|
||||
update_local_friends(self, otherList)
|
||||
return dic
|
||||
|
||||
|
||||
def show_mobile_login(self):
|
||||
url = '%s/webwxstatusnotify?lang=zh_CN&pass_ticket=%s' % (
|
||||
self.loginInfo['url'], self.loginInfo['pass_ticket'])
|
||||
data = {
|
||||
'BaseRequest': self.loginInfo['BaseRequest'],
|
||||
'Code': 3,
|
||||
'FromUserName': self.storageClass.userName,
|
||||
'ToUserName': self.storageClass.userName,
|
||||
'ClientMsgId': int(time.time()), }
|
||||
headers = {
|
||||
'ContentType': 'application/json; charset=UTF-8',
|
||||
'User-Agent': config.USER_AGENT, }
|
||||
r = self.s.post(url, data=json.dumps(data), headers=headers)
|
||||
return ReturnValue(rawResponse=r)
|
||||
|
||||
|
||||
def start_receiving(self, exitCallback=None, getReceivingFnOnly=False):
|
||||
self.alive = True
|
||||
|
||||
def maintain_loop():
|
||||
retryCount = 0
|
||||
while self.alive:
|
||||
try:
|
||||
i = sync_check(self)
|
||||
if i is None:
|
||||
self.alive = False
|
||||
elif i == '0':
|
||||
pass
|
||||
else:
|
||||
msgList, contactList = self.get_msg()
|
||||
if msgList:
|
||||
msgList = produce_msg(self, msgList)
|
||||
for msg in msgList:
|
||||
self.msgList.put(msg)
|
||||
if contactList:
|
||||
chatroomList, otherList = [], []
|
||||
for contact in contactList:
|
||||
if '@@' in contact['UserName']:
|
||||
chatroomList.append(contact)
|
||||
else:
|
||||
otherList.append(contact)
|
||||
chatroomMsg = update_local_chatrooms(
|
||||
self, chatroomList)
|
||||
chatroomMsg['User'] = self.loginInfo['User']
|
||||
self.msgList.put(chatroomMsg)
|
||||
update_local_friends(self, otherList)
|
||||
retryCount = 0
|
||||
except requests.exceptions.ReadTimeout:
|
||||
pass
|
||||
except:
|
||||
retryCount += 1
|
||||
logger.error(traceback.format_exc())
|
||||
if self.receivingRetryCount < retryCount:
|
||||
self.alive = False
|
||||
else:
|
||||
time.sleep(1)
|
||||
self.logout()
|
||||
if hasattr(exitCallback, '__call__'):
|
||||
exitCallback()
|
||||
else:
|
||||
logger.info('LOG OUT!')
|
||||
if getReceivingFnOnly:
|
||||
return maintain_loop
|
||||
else:
|
||||
maintainThread = threading.Thread(target=maintain_loop)
|
||||
maintainThread.setDaemon(True)
|
||||
maintainThread.start()
|
||||
|
||||
|
||||
def sync_check(self):
|
||||
url = '%s/synccheck' % self.loginInfo.get('syncUrl', self.loginInfo['url'])
|
||||
params = {
|
||||
'r': int(time.time() * 1000),
|
||||
'skey': self.loginInfo['skey'],
|
||||
'sid': self.loginInfo['wxsid'],
|
||||
'uin': self.loginInfo['wxuin'],
|
||||
'deviceid': self.loginInfo['deviceid'],
|
||||
'synckey': self.loginInfo['synckey'],
|
||||
'_': self.loginInfo['logintime'], }
|
||||
headers = {'User-Agent': config.USER_AGENT}
|
||||
self.loginInfo['logintime'] += 1
|
||||
try:
|
||||
r = self.s.get(url, params=params, headers=headers,
|
||||
timeout=config.TIMEOUT)
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
try:
|
||||
if not isinstance(e.args[0].args[1], BadStatusLine):
|
||||
raise
|
||||
# will return a package with status '0 -'
|
||||
# and value like:
|
||||
# 6f:00:8a:9c:09:74:e4:d8:e0:14:bf:96:3a:56:a0:64:1b:a4:25:5d:12:f4:31:a5:30:f1:c6:48:5f:c3:75:6a:99:93
|
||||
# seems like status of typing, but before I make further achievement code will remain like this
|
||||
return '2'
|
||||
except:
|
||||
raise
|
||||
r.raise_for_status()
|
||||
regx = r'window.synccheck={retcode:"(\d+)",selector:"(\d+)"}'
|
||||
pm = re.search(regx, r.text)
|
||||
if pm is None or pm.group(1) != '0':
|
||||
logger.debug('Unexpected sync check result: %s' % r.text)
|
||||
return None
|
||||
return pm.group(2)
|
||||
|
||||
|
||||
def get_msg(self):
|
||||
self.loginInfo['deviceid'] = 'e' + repr(random.random())[2:17]
|
||||
url = '%s/webwxsync?sid=%s&skey=%s&pass_ticket=%s' % (
|
||||
self.loginInfo['url'], self.loginInfo['wxsid'],
|
||||
self.loginInfo['skey'], self.loginInfo['pass_ticket'])
|
||||
data = {
|
||||
'BaseRequest': self.loginInfo['BaseRequest'],
|
||||
'SyncKey': self.loginInfo['SyncKey'],
|
||||
'rr': ~int(time.time()), }
|
||||
headers = {
|
||||
'ContentType': 'application/json; charset=UTF-8',
|
||||
'User-Agent': config.USER_AGENT}
|
||||
r = self.s.post(url, data=json.dumps(data),
|
||||
headers=headers, timeout=config.TIMEOUT)
|
||||
dic = json.loads(r.content.decode('utf-8', 'replace'))
|
||||
if dic['BaseResponse']['Ret'] != 0:
|
||||
return None, None
|
||||
self.loginInfo['SyncKey'] = dic['SyncKey']
|
||||
self.loginInfo['synckey'] = '|'.join(['%s_%s' % (item['Key'], item['Val'])
|
||||
for item in dic['SyncCheckKey']['List']])
|
||||
return dic['AddMsgList'], dic['ModContactList']
|
||||
|
||||
|
||||
def logout(self):
|
||||
if self.alive:
|
||||
url = '%s/webwxlogout' % self.loginInfo['url']
|
||||
params = {
|
||||
'redirect': 1,
|
||||
'type': 1,
|
||||
'skey': self.loginInfo['skey'], }
|
||||
headers = {'User-Agent': config.USER_AGENT}
|
||||
self.s.get(url, params=params, headers=headers)
|
||||
self.alive = False
|
||||
self.isLogging = False
|
||||
self.s.cookies.clear()
|
||||
del self.chatroomList[:]
|
||||
del self.memberList[:]
|
||||
del self.mpList[:]
|
||||
return ReturnValue({'BaseResponse': {
|
||||
'ErrMsg': 'logout successfully.',
|
||||
'Ret': 0, }})
|
||||
528
lib/itchat/components/messages.py
Normal file
528
lib/itchat/components/messages.py
Normal file
@@ -0,0 +1,528 @@
|
||||
import os, time, re, io
|
||||
import json
|
||||
import mimetypes, hashlib
|
||||
import logging
|
||||
from collections import OrderedDict
|
||||
|
||||
import requests
|
||||
|
||||
from .. import config, utils
|
||||
from ..returnvalues import ReturnValue
|
||||
from ..storage import templates
|
||||
from .contact import update_local_uin
|
||||
|
||||
logger = logging.getLogger('itchat')
|
||||
|
||||
def load_messages(core):
|
||||
core.send_raw_msg = send_raw_msg
|
||||
core.send_msg = send_msg
|
||||
core.upload_file = upload_file
|
||||
core.send_file = send_file
|
||||
core.send_image = send_image
|
||||
core.send_video = send_video
|
||||
core.send = send
|
||||
core.revoke = revoke
|
||||
|
||||
def get_download_fn(core, url, msgId):
|
||||
def download_fn(downloadDir=None):
|
||||
params = {
|
||||
'msgid': msgId,
|
||||
'skey': core.loginInfo['skey'],}
|
||||
headers = { 'User-Agent' : config.USER_AGENT }
|
||||
r = core.s.get(url, params=params, stream=True, headers = headers)
|
||||
tempStorage = io.BytesIO()
|
||||
for block in r.iter_content(1024):
|
||||
tempStorage.write(block)
|
||||
if downloadDir is None:
|
||||
return tempStorage.getvalue()
|
||||
with open(downloadDir, 'wb') as f:
|
||||
f.write(tempStorage.getvalue())
|
||||
tempStorage.seek(0)
|
||||
return ReturnValue({'BaseResponse': {
|
||||
'ErrMsg': 'Successfully downloaded',
|
||||
'Ret': 0, },
|
||||
'PostFix': utils.get_image_postfix(tempStorage.read(20)), })
|
||||
return download_fn
|
||||
|
||||
def produce_msg(core, msgList):
|
||||
''' for messages types
|
||||
* 40 msg, 43 videochat, 50 VOIPMSG, 52 voipnotifymsg
|
||||
* 53 webwxvoipnotifymsg, 9999 sysnotice
|
||||
'''
|
||||
rl = []
|
||||
srl = [40, 43, 50, 52, 53, 9999]
|
||||
for m in msgList:
|
||||
# get actual opposite
|
||||
if m['FromUserName'] == core.storageClass.userName:
|
||||
actualOpposite = m['ToUserName']
|
||||
else:
|
||||
actualOpposite = m['FromUserName']
|
||||
# produce basic message
|
||||
if '@@' in m['FromUserName'] or '@@' in m['ToUserName']:
|
||||
produce_group_chat(core, m)
|
||||
else:
|
||||
utils.msg_formatter(m, 'Content')
|
||||
# set user of msg
|
||||
if '@@' in actualOpposite:
|
||||
m['User'] = core.search_chatrooms(userName=actualOpposite) or \
|
||||
templates.Chatroom({'UserName': actualOpposite})
|
||||
# we don't need to update chatroom here because we have
|
||||
# updated once when producing basic message
|
||||
elif actualOpposite in ('filehelper', 'fmessage'):
|
||||
m['User'] = templates.User({'UserName': actualOpposite})
|
||||
else:
|
||||
m['User'] = core.search_mps(userName=actualOpposite) or \
|
||||
core.search_friends(userName=actualOpposite) or \
|
||||
templates.User(userName=actualOpposite)
|
||||
# by default we think there may be a user missing not a mp
|
||||
m['User'].core = core
|
||||
if m['MsgType'] == 1: # words
|
||||
if m['Url']:
|
||||
regx = r'(.+?\(.+?\))'
|
||||
data = re.search(regx, m['Content'])
|
||||
data = 'Map' if data is None else data.group(1)
|
||||
msg = {
|
||||
'Type': 'Map',
|
||||
'Text': data,}
|
||||
else:
|
||||
msg = {
|
||||
'Type': 'Text',
|
||||
'Text': m['Content'],}
|
||||
elif m['MsgType'] == 3 or m['MsgType'] == 47: # picture
|
||||
download_fn = get_download_fn(core,
|
||||
'%s/webwxgetmsgimg' % core.loginInfo['url'], m['NewMsgId'])
|
||||
msg = {
|
||||
'Type' : 'Picture',
|
||||
'FileName' : '%s.%s' % (time.strftime('%y%m%d-%H%M%S', time.localtime()),
|
||||
'png' if m['MsgType'] == 3 else 'gif'),
|
||||
'Text' : download_fn, }
|
||||
elif m['MsgType'] == 34: # voice
|
||||
download_fn = get_download_fn(core,
|
||||
'%s/webwxgetvoice' % core.loginInfo['url'], m['NewMsgId'])
|
||||
msg = {
|
||||
'Type': 'Recording',
|
||||
'FileName' : '%s.mp3' % time.strftime('%y%m%d-%H%M%S', time.localtime()),
|
||||
'Text': download_fn,}
|
||||
elif m['MsgType'] == 37: # friends
|
||||
m['User']['UserName'] = m['RecommendInfo']['UserName']
|
||||
msg = {
|
||||
'Type': 'Friends',
|
||||
'Text': {
|
||||
'status' : m['Status'],
|
||||
'userName' : m['RecommendInfo']['UserName'],
|
||||
'verifyContent' : m['Ticket'],
|
||||
'autoUpdate' : m['RecommendInfo'], }, }
|
||||
m['User'].verifyDict = msg['Text']
|
||||
elif m['MsgType'] == 42: # name card
|
||||
msg = {
|
||||
'Type': 'Card',
|
||||
'Text': m['RecommendInfo'], }
|
||||
elif m['MsgType'] in (43, 62): # tiny video
|
||||
msgId = m['MsgId']
|
||||
def download_video(videoDir=None):
|
||||
url = '%s/webwxgetvideo' % core.loginInfo['url']
|
||||
params = {
|
||||
'msgid': msgId,
|
||||
'skey': core.loginInfo['skey'],}
|
||||
headers = {'Range': 'bytes=0-', 'User-Agent' : config.USER_AGENT }
|
||||
r = core.s.get(url, params=params, headers=headers, stream=True)
|
||||
tempStorage = io.BytesIO()
|
||||
for block in r.iter_content(1024):
|
||||
tempStorage.write(block)
|
||||
if videoDir is None:
|
||||
return tempStorage.getvalue()
|
||||
with open(videoDir, 'wb') as f:
|
||||
f.write(tempStorage.getvalue())
|
||||
return ReturnValue({'BaseResponse': {
|
||||
'ErrMsg': 'Successfully downloaded',
|
||||
'Ret': 0, }})
|
||||
msg = {
|
||||
'Type': 'Video',
|
||||
'FileName' : '%s.mp4' % time.strftime('%y%m%d-%H%M%S', time.localtime()),
|
||||
'Text': download_video, }
|
||||
elif m['MsgType'] == 49: # sharing
|
||||
if m['AppMsgType'] == 0: # chat history
|
||||
msg = {
|
||||
'Type': 'Note',
|
||||
'Text': m['Content'], }
|
||||
elif m['AppMsgType'] == 6:
|
||||
rawMsg = m
|
||||
cookiesList = {name:data for name,data in core.s.cookies.items()}
|
||||
def download_atta(attaDir=None):
|
||||
url = core.loginInfo['fileUrl'] + '/webwxgetmedia'
|
||||
params = {
|
||||
'sender': rawMsg['FromUserName'],
|
||||
'mediaid': rawMsg['MediaId'],
|
||||
'filename': rawMsg['FileName'],
|
||||
'fromuser': core.loginInfo['wxuin'],
|
||||
'pass_ticket': 'undefined',
|
||||
'webwx_data_ticket': cookiesList['webwx_data_ticket'],}
|
||||
headers = { 'User-Agent' : config.USER_AGENT }
|
||||
r = core.s.get(url, params=params, stream=True, headers=headers)
|
||||
tempStorage = io.BytesIO()
|
||||
for block in r.iter_content(1024):
|
||||
tempStorage.write(block)
|
||||
if attaDir is None:
|
||||
return tempStorage.getvalue()
|
||||
with open(attaDir, 'wb') as f:
|
||||
f.write(tempStorage.getvalue())
|
||||
return ReturnValue({'BaseResponse': {
|
||||
'ErrMsg': 'Successfully downloaded',
|
||||
'Ret': 0, }})
|
||||
msg = {
|
||||
'Type': 'Attachment',
|
||||
'Text': download_atta, }
|
||||
elif m['AppMsgType'] == 8:
|
||||
download_fn = get_download_fn(core,
|
||||
'%s/webwxgetmsgimg' % core.loginInfo['url'], m['NewMsgId'])
|
||||
msg = {
|
||||
'Type' : 'Picture',
|
||||
'FileName' : '%s.gif' % (
|
||||
time.strftime('%y%m%d-%H%M%S', time.localtime())),
|
||||
'Text' : download_fn, }
|
||||
elif m['AppMsgType'] == 17:
|
||||
msg = {
|
||||
'Type': 'Note',
|
||||
'Text': m['FileName'], }
|
||||
elif m['AppMsgType'] == 2000:
|
||||
regx = r'\[CDATA\[(.+?)\][\s\S]+?\[CDATA\[(.+?)\]'
|
||||
data = re.search(regx, m['Content'])
|
||||
if data:
|
||||
data = data.group(2).split(u'\u3002')[0]
|
||||
else:
|
||||
data = 'You may found detailed info in Content key.'
|
||||
msg = {
|
||||
'Type': 'Note',
|
||||
'Text': data, }
|
||||
else:
|
||||
msg = {
|
||||
'Type': 'Sharing',
|
||||
'Text': m['FileName'], }
|
||||
elif m['MsgType'] == 51: # phone init
|
||||
msg = update_local_uin(core, m)
|
||||
elif m['MsgType'] == 10000:
|
||||
msg = {
|
||||
'Type': 'Note',
|
||||
'Text': m['Content'],}
|
||||
elif m['MsgType'] == 10002:
|
||||
regx = r'\[CDATA\[(.+?)\]\]'
|
||||
data = re.search(regx, m['Content'])
|
||||
data = 'System message' if data is None else data.group(1).replace('\\', '')
|
||||
msg = {
|
||||
'Type': 'Note',
|
||||
'Text': data, }
|
||||
elif m['MsgType'] in srl:
|
||||
msg = {
|
||||
'Type': 'Useless',
|
||||
'Text': 'UselessMsg', }
|
||||
else:
|
||||
logger.debug('Useless message received: %s\n%s' % (m['MsgType'], str(m)))
|
||||
msg = {
|
||||
'Type': 'Useless',
|
||||
'Text': 'UselessMsg', }
|
||||
m = dict(m, **msg)
|
||||
rl.append(m)
|
||||
return rl
|
||||
|
||||
def produce_group_chat(core, msg):
|
||||
r = re.match('(@[0-9a-z]*?):<br/>(.*)$', msg['Content'])
|
||||
if r:
|
||||
actualUserName, content = r.groups()
|
||||
chatroomUserName = msg['FromUserName']
|
||||
elif msg['FromUserName'] == core.storageClass.userName:
|
||||
actualUserName = core.storageClass.userName
|
||||
content = msg['Content']
|
||||
chatroomUserName = msg['ToUserName']
|
||||
else:
|
||||
msg['ActualUserName'] = core.storageClass.userName
|
||||
msg['ActualNickName'] = core.storageClass.nickName
|
||||
msg['IsAt'] = False
|
||||
utils.msg_formatter(msg, 'Content')
|
||||
return
|
||||
chatroom = core.storageClass.search_chatrooms(userName=chatroomUserName)
|
||||
member = utils.search_dict_list((chatroom or {}).get(
|
||||
'MemberList') or [], 'UserName', actualUserName)
|
||||
if member is None:
|
||||
chatroom = core.update_chatroom(chatroomUserName)
|
||||
member = utils.search_dict_list((chatroom or {}).get(
|
||||
'MemberList') or [], 'UserName', actualUserName)
|
||||
if member is None:
|
||||
logger.debug('chatroom member fetch failed with %s' % actualUserName)
|
||||
msg['ActualNickName'] = ''
|
||||
msg['IsAt'] = False
|
||||
else:
|
||||
msg['ActualNickName'] = member.get('DisplayName', '') or member['NickName']
|
||||
atFlag = '@' + (chatroom['Self'].get('DisplayName', '') or core.storageClass.nickName)
|
||||
msg['IsAt'] = (
|
||||
(atFlag + (u'\u2005' if u'\u2005' in msg['Content'] else ' '))
|
||||
in msg['Content'] or msg['Content'].endswith(atFlag))
|
||||
msg['ActualUserName'] = actualUserName
|
||||
msg['Content'] = content
|
||||
utils.msg_formatter(msg, 'Content')
|
||||
|
||||
def send_raw_msg(self, msgType, content, toUserName):
|
||||
url = '%s/webwxsendmsg' % self.loginInfo['url']
|
||||
data = {
|
||||
'BaseRequest': self.loginInfo['BaseRequest'],
|
||||
'Msg': {
|
||||
'Type': msgType,
|
||||
'Content': content,
|
||||
'FromUserName': self.storageClass.userName,
|
||||
'ToUserName': (toUserName if toUserName else self.storageClass.userName),
|
||||
'LocalID': int(time.time() * 1e4),
|
||||
'ClientMsgId': int(time.time() * 1e4),
|
||||
},
|
||||
'Scene': 0, }
|
||||
headers = { 'ContentType': 'application/json; charset=UTF-8', 'User-Agent' : config.USER_AGENT }
|
||||
r = self.s.post(url, headers=headers,
|
||||
data=json.dumps(data, ensure_ascii=False).encode('utf8'))
|
||||
return ReturnValue(rawResponse=r)
|
||||
|
||||
def send_msg(self, msg='Test Message', toUserName=None):
|
||||
logger.debug('Request to send a text message to %s: %s' % (toUserName, msg))
|
||||
r = self.send_raw_msg(1, msg, toUserName)
|
||||
return r
|
||||
|
||||
def _prepare_file(fileDir, file_=None):
|
||||
fileDict = {}
|
||||
if file_:
|
||||
if hasattr(file_, 'read'):
|
||||
file_ = file_.read()
|
||||
else:
|
||||
return ReturnValue({'BaseResponse': {
|
||||
'ErrMsg': 'file_ param should be opened file',
|
||||
'Ret': -1005, }})
|
||||
else:
|
||||
if not utils.check_file(fileDir):
|
||||
return ReturnValue({'BaseResponse': {
|
||||
'ErrMsg': 'No file found in specific dir',
|
||||
'Ret': -1002, }})
|
||||
with open(fileDir, 'rb') as f:
|
||||
file_ = f.read()
|
||||
fileDict['fileSize'] = len(file_)
|
||||
fileDict['fileMd5'] = hashlib.md5(file_).hexdigest()
|
||||
fileDict['file_'] = io.BytesIO(file_)
|
||||
return fileDict
|
||||
|
||||
def upload_file(self, fileDir, isPicture=False, isVideo=False,
|
||||
toUserName='filehelper', file_=None, preparedFile=None):
|
||||
logger.debug('Request to upload a %s: %s' % (
|
||||
'picture' if isPicture else 'video' if isVideo else 'file', fileDir))
|
||||
if not preparedFile:
|
||||
preparedFile = _prepare_file(fileDir, file_)
|
||||
if not preparedFile:
|
||||
return preparedFile
|
||||
fileSize, fileMd5, file_ = \
|
||||
preparedFile['fileSize'], preparedFile['fileMd5'], preparedFile['file_']
|
||||
fileSymbol = 'pic' if isPicture else 'video' if isVideo else'doc'
|
||||
chunks = int((fileSize - 1) / 524288) + 1
|
||||
clientMediaId = int(time.time() * 1e4)
|
||||
uploadMediaRequest = json.dumps(OrderedDict([
|
||||
('UploadType', 2),
|
||||
('BaseRequest', self.loginInfo['BaseRequest']),
|
||||
('ClientMediaId', clientMediaId),
|
||||
('TotalLen', fileSize),
|
||||
('StartPos', 0),
|
||||
('DataLen', fileSize),
|
||||
('MediaType', 4),
|
||||
('FromUserName', self.storageClass.userName),
|
||||
('ToUserName', toUserName),
|
||||
('FileMd5', fileMd5)]
|
||||
), separators = (',', ':'))
|
||||
r = {'BaseResponse': {'Ret': -1005, 'ErrMsg': 'Empty file detected'}}
|
||||
for chunk in range(chunks):
|
||||
r = upload_chunk_file(self, fileDir, fileSymbol, fileSize,
|
||||
file_, chunk, chunks, uploadMediaRequest)
|
||||
file_.close()
|
||||
if isinstance(r, dict):
|
||||
return ReturnValue(r)
|
||||
return ReturnValue(rawResponse=r)
|
||||
|
||||
def upload_chunk_file(core, fileDir, fileSymbol, fileSize,
|
||||
file_, chunk, chunks, uploadMediaRequest):
|
||||
url = core.loginInfo.get('fileUrl', core.loginInfo['url']) + \
|
||||
'/webwxuploadmedia?f=json'
|
||||
# save it on server
|
||||
cookiesList = {name:data for name,data in core.s.cookies.items()}
|
||||
fileType = mimetypes.guess_type(fileDir)[0] or 'application/octet-stream'
|
||||
fileName = utils.quote(os.path.basename(fileDir))
|
||||
files = OrderedDict([
|
||||
('id', (None, 'WU_FILE_0')),
|
||||
('name', (None, fileName)),
|
||||
('type', (None, fileType)),
|
||||
('lastModifiedDate', (None, time.strftime('%a %b %d %Y %H:%M:%S GMT+0800 (CST)'))),
|
||||
('size', (None, str(fileSize))),
|
||||
('chunks', (None, None)),
|
||||
('chunk', (None, None)),
|
||||
('mediatype', (None, fileSymbol)),
|
||||
('uploadmediarequest', (None, uploadMediaRequest)),
|
||||
('webwx_data_ticket', (None, cookiesList['webwx_data_ticket'])),
|
||||
('pass_ticket', (None, core.loginInfo['pass_ticket'])),
|
||||
('filename' , (fileName, file_.read(524288), 'application/octet-stream'))])
|
||||
if chunks == 1:
|
||||
del files['chunk']; del files['chunks']
|
||||
else:
|
||||
files['chunk'], files['chunks'] = (None, str(chunk)), (None, str(chunks))
|
||||
headers = { 'User-Agent' : config.USER_AGENT }
|
||||
return core.s.post(url, files=files, headers=headers, timeout=config.TIMEOUT)
|
||||
|
||||
def send_file(self, fileDir, toUserName=None, mediaId=None, file_=None):
|
||||
logger.debug('Request to send a file(mediaId: %s) to %s: %s' % (
|
||||
mediaId, toUserName, fileDir))
|
||||
if hasattr(fileDir, 'read'):
|
||||
return ReturnValue({'BaseResponse': {
|
||||
'ErrMsg': 'fileDir param should not be an opened file in send_file',
|
||||
'Ret': -1005, }})
|
||||
if toUserName is None:
|
||||
toUserName = self.storageClass.userName
|
||||
preparedFile = _prepare_file(fileDir, file_)
|
||||
if not preparedFile:
|
||||
return preparedFile
|
||||
fileSize = preparedFile['fileSize']
|
||||
if mediaId is None:
|
||||
r = self.upload_file(fileDir, preparedFile=preparedFile)
|
||||
if r:
|
||||
mediaId = r['MediaId']
|
||||
else:
|
||||
return r
|
||||
url = '%s/webwxsendappmsg?fun=async&f=json' % self.loginInfo['url']
|
||||
data = {
|
||||
'BaseRequest': self.loginInfo['BaseRequest'],
|
||||
'Msg': {
|
||||
'Type': 6,
|
||||
'Content': ("<appmsg appid='wxeb7ec651dd0aefa9' sdkver=''><title>%s</title>" % os.path.basename(fileDir) +
|
||||
"<des></des><action></action><type>6</type><content></content><url></url><lowurl></lowurl>" +
|
||||
"<appattach><totallen>%s</totallen><attachid>%s</attachid>" % (str(fileSize), mediaId) +
|
||||
"<fileext>%s</fileext></appattach><extinfo></extinfo></appmsg>" % os.path.splitext(fileDir)[1].replace('.','')),
|
||||
'FromUserName': self.storageClass.userName,
|
||||
'ToUserName': toUserName,
|
||||
'LocalID': int(time.time() * 1e4),
|
||||
'ClientMsgId': int(time.time() * 1e4), },
|
||||
'Scene': 0, }
|
||||
headers = {
|
||||
'User-Agent': config.USER_AGENT,
|
||||
'Content-Type': 'application/json;charset=UTF-8', }
|
||||
r = self.s.post(url, headers=headers,
|
||||
data=json.dumps(data, ensure_ascii=False).encode('utf8'))
|
||||
return ReturnValue(rawResponse=r)
|
||||
|
||||
def send_image(self, fileDir=None, toUserName=None, mediaId=None, file_=None):
|
||||
logger.debug('Request to send a image(mediaId: %s) to %s: %s' % (
|
||||
mediaId, toUserName, fileDir))
|
||||
if fileDir or file_:
|
||||
if hasattr(fileDir, 'read'):
|
||||
file_, fileDir = fileDir, None
|
||||
if fileDir is None:
|
||||
fileDir = 'tmp.jpg' # specific fileDir to send gifs
|
||||
else:
|
||||
return ReturnValue({'BaseResponse': {
|
||||
'ErrMsg': 'Either fileDir or file_ should be specific',
|
||||
'Ret': -1005, }})
|
||||
if toUserName is None:
|
||||
toUserName = self.storageClass.userName
|
||||
if mediaId is None:
|
||||
r = self.upload_file(fileDir, isPicture=not fileDir[-4:] == '.gif', file_=file_)
|
||||
if r:
|
||||
mediaId = r['MediaId']
|
||||
else:
|
||||
return r
|
||||
url = '%s/webwxsendmsgimg?fun=async&f=json' % self.loginInfo['url']
|
||||
data = {
|
||||
'BaseRequest': self.loginInfo['BaseRequest'],
|
||||
'Msg': {
|
||||
'Type': 3,
|
||||
'MediaId': mediaId,
|
||||
'FromUserName': self.storageClass.userName,
|
||||
'ToUserName': toUserName,
|
||||
'LocalID': int(time.time() * 1e4),
|
||||
'ClientMsgId': int(time.time() * 1e4), },
|
||||
'Scene': 0, }
|
||||
if fileDir[-4:] == '.gif':
|
||||
url = '%s/webwxsendemoticon?fun=sys' % self.loginInfo['url']
|
||||
data['Msg']['Type'] = 47
|
||||
data['Msg']['EmojiFlag'] = 2
|
||||
headers = {
|
||||
'User-Agent': config.USER_AGENT,
|
||||
'Content-Type': 'application/json;charset=UTF-8', }
|
||||
r = self.s.post(url, headers=headers,
|
||||
data=json.dumps(data, ensure_ascii=False).encode('utf8'))
|
||||
return ReturnValue(rawResponse=r)
|
||||
|
||||
def send_video(self, fileDir=None, toUserName=None, mediaId=None, file_=None):
|
||||
logger.debug('Request to send a video(mediaId: %s) to %s: %s' % (
|
||||
mediaId, toUserName, fileDir))
|
||||
if fileDir or file_:
|
||||
if hasattr(fileDir, 'read'):
|
||||
file_, fileDir = fileDir, None
|
||||
if fileDir is None:
|
||||
fileDir = 'tmp.mp4' # specific fileDir to send other formats
|
||||
else:
|
||||
return ReturnValue({'BaseResponse': {
|
||||
'ErrMsg': 'Either fileDir or file_ should be specific',
|
||||
'Ret': -1005, }})
|
||||
if toUserName is None:
|
||||
toUserName = self.storageClass.userName
|
||||
if mediaId is None:
|
||||
r = self.upload_file(fileDir, isVideo=True, file_=file_)
|
||||
if r:
|
||||
mediaId = r['MediaId']
|
||||
else:
|
||||
return r
|
||||
url = '%s/webwxsendvideomsg?fun=async&f=json&pass_ticket=%s' % (
|
||||
self.loginInfo['url'], self.loginInfo['pass_ticket'])
|
||||
data = {
|
||||
'BaseRequest': self.loginInfo['BaseRequest'],
|
||||
'Msg': {
|
||||
'Type' : 43,
|
||||
'MediaId' : mediaId,
|
||||
'FromUserName' : self.storageClass.userName,
|
||||
'ToUserName' : toUserName,
|
||||
'LocalID' : int(time.time() * 1e4),
|
||||
'ClientMsgId' : int(time.time() * 1e4), },
|
||||
'Scene': 0, }
|
||||
headers = {
|
||||
'User-Agent' : config.USER_AGENT,
|
||||
'Content-Type': 'application/json;charset=UTF-8', }
|
||||
r = self.s.post(url, headers=headers,
|
||||
data=json.dumps(data, ensure_ascii=False).encode('utf8'))
|
||||
return ReturnValue(rawResponse=r)
|
||||
|
||||
def send(self, msg, toUserName=None, mediaId=None):
|
||||
if not msg:
|
||||
r = ReturnValue({'BaseResponse': {
|
||||
'ErrMsg': 'No message.',
|
||||
'Ret': -1005, }})
|
||||
elif msg[:5] == '@fil@':
|
||||
if mediaId is None:
|
||||
r = self.send_file(msg[5:], toUserName)
|
||||
else:
|
||||
r = self.send_file(msg[5:], toUserName, mediaId)
|
||||
elif msg[:5] == '@img@':
|
||||
if mediaId is None:
|
||||
r = self.send_image(msg[5:], toUserName)
|
||||
else:
|
||||
r = self.send_image(msg[5:], toUserName, mediaId)
|
||||
elif msg[:5] == '@msg@':
|
||||
r = self.send_msg(msg[5:], toUserName)
|
||||
elif msg[:5] == '@vid@':
|
||||
if mediaId is None:
|
||||
r = self.send_video(msg[5:], toUserName)
|
||||
else:
|
||||
r = self.send_video(msg[5:], toUserName, mediaId)
|
||||
else:
|
||||
r = self.send_msg(msg, toUserName)
|
||||
return r
|
||||
|
||||
def revoke(self, msgId, toUserName, localId=None):
|
||||
url = '%s/webwxrevokemsg' % self.loginInfo['url']
|
||||
data = {
|
||||
'BaseRequest': self.loginInfo['BaseRequest'],
|
||||
"ClientMsgId": localId or str(time.time() * 1e3),
|
||||
"SvrMsgId": msgId,
|
||||
"ToUserName": toUserName}
|
||||
headers = {
|
||||
'ContentType': 'application/json; charset=UTF-8',
|
||||
'User-Agent' : config.USER_AGENT }
|
||||
r = self.s.post(url, headers=headers,
|
||||
data=json.dumps(data, ensure_ascii=False).encode('utf8'))
|
||||
return ReturnValue(rawResponse=r)
|
||||
103
lib/itchat/components/register.py
Normal file
103
lib/itchat/components/register.py
Normal file
@@ -0,0 +1,103 @@
|
||||
import logging, traceback, sys, threading
|
||||
try:
|
||||
import Queue
|
||||
except ImportError:
|
||||
import queue as Queue
|
||||
|
||||
from ..log import set_logging
|
||||
from ..utils import test_connect
|
||||
from ..storage import templates
|
||||
|
||||
logger = logging.getLogger('itchat')
|
||||
|
||||
def load_register(core):
|
||||
core.auto_login = auto_login
|
||||
core.configured_reply = configured_reply
|
||||
core.msg_register = msg_register
|
||||
core.run = run
|
||||
|
||||
def auto_login(self, hotReload=False, statusStorageDir='itchat.pkl',
|
||||
enableCmdQR=False, picDir=None, qrCallback=None,
|
||||
loginCallback=None, exitCallback=None):
|
||||
if not test_connect():
|
||||
logger.info("You can't get access to internet or wechat domain, so exit.")
|
||||
sys.exit()
|
||||
self.useHotReload = hotReload
|
||||
self.hotReloadDir = statusStorageDir
|
||||
if hotReload:
|
||||
if self.load_login_status(statusStorageDir,
|
||||
loginCallback=loginCallback, exitCallback=exitCallback):
|
||||
return
|
||||
self.login(enableCmdQR=enableCmdQR, picDir=picDir, qrCallback=qrCallback,
|
||||
loginCallback=loginCallback, exitCallback=exitCallback)
|
||||
self.dump_login_status(statusStorageDir)
|
||||
else:
|
||||
self.login(enableCmdQR=enableCmdQR, picDir=picDir, qrCallback=qrCallback,
|
||||
loginCallback=loginCallback, exitCallback=exitCallback)
|
||||
|
||||
def configured_reply(self):
|
||||
''' determine the type of message and reply if its method is defined
|
||||
however, I use a strange way to determine whether a msg is from massive platform
|
||||
I haven't found a better solution here
|
||||
The main problem I'm worrying about is the mismatching of new friends added on phone
|
||||
If you have any good idea, pleeeease report an issue. I will be more than grateful.
|
||||
'''
|
||||
try:
|
||||
msg = self.msgList.get(timeout=1)
|
||||
except Queue.Empty:
|
||||
pass
|
||||
else:
|
||||
if isinstance(msg['User'], templates.User):
|
||||
replyFn = self.functionDict['FriendChat'].get(msg['Type'])
|
||||
elif isinstance(msg['User'], templates.MassivePlatform):
|
||||
replyFn = self.functionDict['MpChat'].get(msg['Type'])
|
||||
elif isinstance(msg['User'], templates.Chatroom):
|
||||
replyFn = self.functionDict['GroupChat'].get(msg['Type'])
|
||||
if replyFn is None:
|
||||
r = None
|
||||
else:
|
||||
try:
|
||||
r = replyFn(msg)
|
||||
if r is not None:
|
||||
self.send(r, msg.get('FromUserName'))
|
||||
except:
|
||||
logger.warning(traceback.format_exc())
|
||||
|
||||
def msg_register(self, msgType, isFriendChat=False, isGroupChat=False, isMpChat=False):
|
||||
''' a decorator constructor
|
||||
return a specific decorator based on information given '''
|
||||
if not (isinstance(msgType, list) or isinstance(msgType, tuple)):
|
||||
msgType = [msgType]
|
||||
def _msg_register(fn):
|
||||
for _msgType in msgType:
|
||||
if isFriendChat:
|
||||
self.functionDict['FriendChat'][_msgType] = fn
|
||||
if isGroupChat:
|
||||
self.functionDict['GroupChat'][_msgType] = fn
|
||||
if isMpChat:
|
||||
self.functionDict['MpChat'][_msgType] = fn
|
||||
if not any((isFriendChat, isGroupChat, isMpChat)):
|
||||
self.functionDict['FriendChat'][_msgType] = fn
|
||||
return fn
|
||||
return _msg_register
|
||||
|
||||
def run(self, debug=False, blockThread=True):
|
||||
logger.info('Start auto replying.')
|
||||
if debug:
|
||||
set_logging(loggingLevel=logging.DEBUG)
|
||||
def reply_fn():
|
||||
try:
|
||||
while self.alive:
|
||||
self.configured_reply()
|
||||
except KeyboardInterrupt:
|
||||
if self.useHotReload:
|
||||
self.dump_login_status()
|
||||
self.alive = False
|
||||
logger.debug('itchat received an ^C and exit.')
|
||||
logger.info('Bye~')
|
||||
if blockThread:
|
||||
reply_fn()
|
||||
else:
|
||||
replyThread = threading.Thread(target=reply_fn)
|
||||
replyThread.setDaemon(True)
|
||||
replyThread.start()
|
||||
17
lib/itchat/config.py
Normal file
17
lib/itchat/config.py
Normal file
@@ -0,0 +1,17 @@
|
||||
import os, platform
|
||||
|
||||
VERSION = '1.5.0.dev'
|
||||
|
||||
# use this envrionment to initialize the async & sync componment
|
||||
ASYNC_COMPONENTS = os.environ.get('ITCHAT_UOS_ASYNC', False)
|
||||
|
||||
BASE_URL = 'https://login.weixin.qq.com'
|
||||
OS = platform.system() # Windows, Linux, Darwin
|
||||
DIR = os.getcwd()
|
||||
DEFAULT_QR = 'QR.png'
|
||||
TIMEOUT = (10, 60)
|
||||
|
||||
USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/54.0.2840.71 Safari/537.36'
|
||||
|
||||
UOS_PATCH_CLIENT_VERSION = '2.0.0'
|
||||
UOS_PATCH_EXTSPAM = 'Go8FCIkFEokFCggwMDAwMDAwMRAGGvAESySibk50w5Wb3uTl2c2h64jVVrV7gNs06GFlWplHQbY/5FfiO++1yH4ykCyNPWKXmco+wfQzK5R98D3so7rJ5LmGFvBLjGceleySrc3SOf2Pc1gVehzJgODeS0lDL3/I/0S2SSE98YgKleq6Uqx6ndTy9yaL9qFxJL7eiA/R3SEfTaW1SBoSITIu+EEkXff+Pv8NHOk7N57rcGk1w0ZzRrQDkXTOXFN2iHYIzAAZPIOY45Lsh+A4slpgnDiaOvRtlQYCt97nmPLuTipOJ8Qc5pM7ZsOsAPPrCQL7nK0I7aPrFDF0q4ziUUKettzW8MrAaiVfmbD1/VkmLNVqqZVvBCtRblXb5FHmtS8FxnqCzYP4WFvz3T0TcrOqwLX1M/DQvcHaGGw0B0y4bZMs7lVScGBFxMj3vbFi2SRKbKhaitxHfYHAOAa0X7/MSS0RNAjdwoyGHeOepXOKY+h3iHeqCvgOH6LOifdHf/1aaZNwSkGotYnYScW8Yx63LnSwba7+hESrtPa/huRmB9KWvMCKbDThL/nne14hnL277EDCSocPu3rOSYjuB9gKSOdVmWsj9Dxb/iZIe+S6AiG29Esm+/eUacSba0k8wn5HhHg9d4tIcixrxveflc8vi2/wNQGVFNsGO6tB5WF0xf/plngOvQ1/ivGV/C1Qpdhzznh0ExAVJ6dwzNg7qIEBaw+BzTJTUuRcPk92Sn6QDn2Pu3mpONaEumacjW4w6ipPnPw+g2TfywJjeEcpSZaP4Q3YV5HG8D6UjWA4GSkBKculWpdCMadx0usMomsSS/74QgpYqcPkmamB4nVv1JxczYITIqItIKjD35IGKAUwAA=='
|
||||
14
lib/itchat/content.py
Normal file
14
lib/itchat/content.py
Normal file
@@ -0,0 +1,14 @@
|
||||
TEXT = 'Text'
|
||||
MAP = 'Map'
|
||||
CARD = 'Card'
|
||||
NOTE = 'Note'
|
||||
SHARING = 'Sharing'
|
||||
PICTURE = 'Picture'
|
||||
RECORDING = VOICE = 'Recording'
|
||||
ATTACHMENT = 'Attachment'
|
||||
VIDEO = 'Video'
|
||||
FRIENDS = 'Friends'
|
||||
SYSTEM = 'System'
|
||||
|
||||
INCOME_MSG = [TEXT, MAP, CARD, NOTE, SHARING, PICTURE,
|
||||
RECORDING, VOICE, ATTACHMENT, VIDEO, FRIENDS, SYSTEM]
|
||||
456
lib/itchat/core.py
Normal file
456
lib/itchat/core.py
Normal file
@@ -0,0 +1,456 @@
|
||||
import requests
|
||||
|
||||
from . import storage
|
||||
|
||||
class Core(object):
|
||||
def __init__(self):
|
||||
''' init is the only method defined in core.py
|
||||
alive is value showing whether core is running
|
||||
- you should call logout method to change it
|
||||
- after logout, a core object can login again
|
||||
storageClass only uses basic python types
|
||||
- so for advanced uses, inherit it yourself
|
||||
receivingRetryCount is for receiving loop retry
|
||||
- it's 5 now, but actually even 1 is enough
|
||||
- failing is failing
|
||||
'''
|
||||
self.alive, self.isLogging = False, False
|
||||
self.storageClass = storage.Storage(self)
|
||||
self.memberList = self.storageClass.memberList
|
||||
self.mpList = self.storageClass.mpList
|
||||
self.chatroomList = self.storageClass.chatroomList
|
||||
self.msgList = self.storageClass.msgList
|
||||
self.loginInfo = {}
|
||||
self.s = requests.Session()
|
||||
self.uuid = None
|
||||
self.functionDict = {'FriendChat': {}, 'GroupChat': {}, 'MpChat': {}}
|
||||
self.useHotReload, self.hotReloadDir = False, 'itchat.pkl'
|
||||
self.receivingRetryCount = 5
|
||||
def login(self, enableCmdQR=False, picDir=None, qrCallback=None,
|
||||
loginCallback=None, exitCallback=None):
|
||||
''' log in like web wechat does
|
||||
for log in
|
||||
- a QR code will be downloaded and opened
|
||||
- then scanning status is logged, it paused for you confirm
|
||||
- finally it logged in and show your nickName
|
||||
for options
|
||||
- enableCmdQR: show qrcode in command line
|
||||
- integers can be used to fit strange char length
|
||||
- picDir: place for storing qrcode
|
||||
- qrCallback: method that should accept uuid, status, qrcode
|
||||
- loginCallback: callback after successfully logged in
|
||||
- if not set, screen is cleared and qrcode is deleted
|
||||
- exitCallback: callback after logged out
|
||||
- it contains calling of logout
|
||||
for usage
|
||||
..code::python
|
||||
|
||||
import itchat
|
||||
itchat.login()
|
||||
|
||||
it is defined in components/login.py
|
||||
and of course every single move in login can be called outside
|
||||
- you may scan source code to see how
|
||||
- and modified according to your own demand
|
||||
'''
|
||||
raise NotImplementedError()
|
||||
def get_QRuuid(self):
|
||||
''' get uuid for qrcode
|
||||
uuid is the symbol of qrcode
|
||||
- for logging in, you need to get a uuid first
|
||||
- for downloading qrcode, you need to pass uuid to it
|
||||
- for checking login status, uuid is also required
|
||||
if uuid has timed out, just get another
|
||||
it is defined in components/login.py
|
||||
'''
|
||||
raise NotImplementedError()
|
||||
def get_QR(self, uuid=None, enableCmdQR=False, picDir=None, qrCallback=None):
|
||||
''' download and show qrcode
|
||||
for options
|
||||
- uuid: if uuid is not set, latest uuid you fetched will be used
|
||||
- enableCmdQR: show qrcode in cmd
|
||||
- picDir: where to store qrcode
|
||||
- qrCallback: method that should accept uuid, status, qrcode
|
||||
it is defined in components/login.py
|
||||
'''
|
||||
raise NotImplementedError()
|
||||
def check_login(self, uuid=None):
|
||||
''' check login status
|
||||
for options:
|
||||
- uuid: if uuid is not set, latest uuid you fetched will be used
|
||||
for return values:
|
||||
- a string will be returned
|
||||
- for meaning of return values
|
||||
- 200: log in successfully
|
||||
- 201: waiting for press confirm
|
||||
- 408: uuid timed out
|
||||
- 0 : unknown error
|
||||
for processing:
|
||||
- syncUrl and fileUrl is set
|
||||
- BaseRequest is set
|
||||
blocks until reaches any of above status
|
||||
it is defined in components/login.py
|
||||
'''
|
||||
raise NotImplementedError()
|
||||
def web_init(self):
|
||||
''' get info necessary for initializing
|
||||
for processing:
|
||||
- own account info is set
|
||||
- inviteStartCount is set
|
||||
- syncKey is set
|
||||
- part of contact is fetched
|
||||
it is defined in components/login.py
|
||||
'''
|
||||
raise NotImplementedError()
|
||||
def show_mobile_login(self):
|
||||
''' show web wechat login sign
|
||||
the sign is on the top of mobile phone wechat
|
||||
sign will be added after sometime even without calling this function
|
||||
it is defined in components/login.py
|
||||
'''
|
||||
raise NotImplementedError()
|
||||
def start_receiving(self, exitCallback=None, getReceivingFnOnly=False):
|
||||
''' open a thread for heart loop and receiving messages
|
||||
for options:
|
||||
- exitCallback: callback after logged out
|
||||
- it contains calling of logout
|
||||
- getReceivingFnOnly: if True thread will not be created and started. Instead, receive fn will be returned.
|
||||
for processing:
|
||||
- messages: msgs are formatted and passed on to registered fns
|
||||
- contact : chatrooms are updated when related info is received
|
||||
it is defined in components/login.py
|
||||
'''
|
||||
raise NotImplementedError()
|
||||
def get_msg(self):
|
||||
''' fetch messages
|
||||
for fetching
|
||||
- method blocks for sometime until
|
||||
- new messages are to be received
|
||||
- or anytime they like
|
||||
- synckey is updated with returned synccheckkey
|
||||
it is defined in components/login.py
|
||||
'''
|
||||
raise NotImplementedError()
|
||||
def logout(self):
|
||||
''' logout
|
||||
if core is now alive
|
||||
logout will tell wechat backstage to logout
|
||||
and core gets ready for another login
|
||||
it is defined in components/login.py
|
||||
'''
|
||||
raise NotImplementedError()
|
||||
def update_chatroom(self, userName, detailedMember=False):
|
||||
''' update chatroom
|
||||
for chatroom contact
|
||||
- a chatroom contact need updating to be detailed
|
||||
- detailed means members, encryid, etc
|
||||
- auto updating of heart loop is a more detailed updating
|
||||
- member uin will also be filled
|
||||
- once called, updated info will be stored
|
||||
for options
|
||||
- userName: 'UserName' key of chatroom or a list of it
|
||||
- detailedMember: whether to get members of contact
|
||||
it is defined in components/contact.py
|
||||
'''
|
||||
raise NotImplementedError()
|
||||
def update_friend(self, userName):
|
||||
''' update chatroom
|
||||
for friend contact
|
||||
- once called, updated info will be stored
|
||||
for options
|
||||
- userName: 'UserName' key of a friend or a list of it
|
||||
it is defined in components/contact.py
|
||||
'''
|
||||
raise NotImplementedError()
|
||||
def get_contact(self, update=False):
|
||||
''' fetch part of contact
|
||||
for part
|
||||
- all the massive platforms and friends are fetched
|
||||
- if update, only starred chatrooms are fetched
|
||||
for options
|
||||
- update: if not set, local value will be returned
|
||||
for results
|
||||
- chatroomList will be returned
|
||||
it is defined in components/contact.py
|
||||
'''
|
||||
raise NotImplementedError()
|
||||
def get_friends(self, update=False):
|
||||
''' fetch friends list
|
||||
for options
|
||||
- update: if not set, local value will be returned
|
||||
for results
|
||||
- a list of friends' info dicts will be returned
|
||||
it is defined in components/contact.py
|
||||
'''
|
||||
raise NotImplementedError()
|
||||
def get_chatrooms(self, update=False, contactOnly=False):
|
||||
''' fetch chatrooms list
|
||||
for options
|
||||
- update: if not set, local value will be returned
|
||||
- contactOnly: if set, only starred chatrooms will be returned
|
||||
for results
|
||||
- a list of chatrooms' info dicts will be returned
|
||||
it is defined in components/contact.py
|
||||
'''
|
||||
raise NotImplementedError()
|
||||
def get_mps(self, update=False):
|
||||
''' fetch massive platforms list
|
||||
for options
|
||||
- update: if not set, local value will be returned
|
||||
for results
|
||||
- a list of platforms' info dicts will be returned
|
||||
it is defined in components/contact.py
|
||||
'''
|
||||
raise NotImplementedError()
|
||||
def set_alias(self, userName, alias):
|
||||
''' set alias for a friend
|
||||
for options
|
||||
- userName: 'UserName' key of info dict
|
||||
- alias: new alias
|
||||
it is defined in components/contact.py
|
||||
'''
|
||||
raise NotImplementedError()
|
||||
def set_pinned(self, userName, isPinned=True):
|
||||
''' set pinned for a friend or a chatroom
|
||||
for options
|
||||
- userName: 'UserName' key of info dict
|
||||
- isPinned: whether to pin
|
||||
it is defined in components/contact.py
|
||||
'''
|
||||
raise NotImplementedError()
|
||||
def accept_friend(self, userName, v4,autoUpdate=True):
|
||||
''' accept a friend or accept a friend
|
||||
for options
|
||||
- userName: 'UserName' for friend's info dict
|
||||
- status:
|
||||
- for adding status should be 2
|
||||
- for accepting status should be 3
|
||||
- ticket: greeting message
|
||||
- userInfo: friend's other info for adding into local storage
|
||||
it is defined in components/contact.py
|
||||
'''
|
||||
raise NotImplementedError()
|
||||
def get_head_img(self, userName=None, chatroomUserName=None, picDir=None):
|
||||
''' place for docs
|
||||
for options
|
||||
- if you want to get chatroom header: only set chatroomUserName
|
||||
- if you want to get friend header: only set userName
|
||||
- if you want to get chatroom member header: set both
|
||||
it is defined in components/contact.py
|
||||
'''
|
||||
raise NotImplementedError()
|
||||
def create_chatroom(self, memberList, topic=''):
|
||||
''' create a chatroom
|
||||
for creating
|
||||
- its calling frequency is strictly limited
|
||||
for options
|
||||
- memberList: list of member info dict
|
||||
- topic: topic of new chatroom
|
||||
it is defined in components/contact.py
|
||||
'''
|
||||
raise NotImplementedError()
|
||||
def set_chatroom_name(self, chatroomUserName, name):
|
||||
''' set chatroom name
|
||||
for setting
|
||||
- it makes an updating of chatroom
|
||||
- which means detailed info will be returned in heart loop
|
||||
for options
|
||||
- chatroomUserName: 'UserName' key of chatroom info dict
|
||||
- name: new chatroom name
|
||||
it is defined in components/contact.py
|
||||
'''
|
||||
raise NotImplementedError()
|
||||
def delete_member_from_chatroom(self, chatroomUserName, memberList):
|
||||
''' deletes members from chatroom
|
||||
for deleting
|
||||
- you can't delete yourself
|
||||
- if so, no one will be deleted
|
||||
- strict-limited frequency
|
||||
for options
|
||||
- chatroomUserName: 'UserName' key of chatroom info dict
|
||||
- memberList: list of members' info dict
|
||||
it is defined in components/contact.py
|
||||
'''
|
||||
raise NotImplementedError()
|
||||
def add_member_into_chatroom(self, chatroomUserName, memberList,
|
||||
useInvitation=False):
|
||||
''' add members into chatroom
|
||||
for adding
|
||||
- you can't add yourself or member already in chatroom
|
||||
- if so, no one will be added
|
||||
- if member will over 40 after adding, invitation must be used
|
||||
- strict-limited frequency
|
||||
for options
|
||||
- chatroomUserName: 'UserName' key of chatroom info dict
|
||||
- memberList: list of members' info dict
|
||||
- useInvitation: if invitation is not required, set this to use
|
||||
it is defined in components/contact.py
|
||||
'''
|
||||
raise NotImplementedError()
|
||||
def send_raw_msg(self, msgType, content, toUserName):
|
||||
''' many messages are sent in a common way
|
||||
for demo
|
||||
.. code:: python
|
||||
|
||||
@itchat.msg_register(itchat.content.CARD)
|
||||
def reply(msg):
|
||||
itchat.send_raw_msg(msg['MsgType'], msg['Content'], msg['FromUserName'])
|
||||
|
||||
there are some little tricks here, you may discover them yourself
|
||||
but remember they are tricks
|
||||
it is defined in components/messages.py
|
||||
'''
|
||||
raise NotImplementedError()
|
||||
def send_msg(self, msg='Test Message', toUserName=None):
|
||||
''' send plain text message
|
||||
for options
|
||||
- msg: should be unicode if there's non-ascii words in msg
|
||||
- toUserName: 'UserName' key of friend dict
|
||||
it is defined in components/messages.py
|
||||
'''
|
||||
raise NotImplementedError()
|
||||
def upload_file(self, fileDir, isPicture=False, isVideo=False,
|
||||
toUserName='filehelper', file_=None, preparedFile=None):
|
||||
''' upload file to server and get mediaId
|
||||
for options
|
||||
- fileDir: dir for file ready for upload
|
||||
- isPicture: whether file is a picture
|
||||
- isVideo: whether file is a video
|
||||
for return values
|
||||
will return a ReturnValue
|
||||
if succeeded, mediaId is in r['MediaId']
|
||||
it is defined in components/messages.py
|
||||
'''
|
||||
raise NotImplementedError()
|
||||
def send_file(self, fileDir, toUserName=None, mediaId=None, file_=None):
|
||||
''' send attachment
|
||||
for options
|
||||
- fileDir: dir for file ready for upload
|
||||
- mediaId: mediaId for file.
|
||||
- if set, file will not be uploaded twice
|
||||
- toUserName: 'UserName' key of friend dict
|
||||
it is defined in components/messages.py
|
||||
'''
|
||||
raise NotImplementedError()
|
||||
def send_image(self, fileDir=None, toUserName=None, mediaId=None, file_=None):
|
||||
''' send image
|
||||
for options
|
||||
- fileDir: dir for file ready for upload
|
||||
- if it's a gif, name it like 'xx.gif'
|
||||
- mediaId: mediaId for file.
|
||||
- if set, file will not be uploaded twice
|
||||
- toUserName: 'UserName' key of friend dict
|
||||
it is defined in components/messages.py
|
||||
'''
|
||||
raise NotImplementedError()
|
||||
def send_video(self, fileDir=None, toUserName=None, mediaId=None, file_=None):
|
||||
''' send video
|
||||
for options
|
||||
- fileDir: dir for file ready for upload
|
||||
- if mediaId is set, it's unnecessary to set fileDir
|
||||
- mediaId: mediaId for file.
|
||||
- if set, file will not be uploaded twice
|
||||
- toUserName: 'UserName' key of friend dict
|
||||
it is defined in components/messages.py
|
||||
'''
|
||||
raise NotImplementedError()
|
||||
def send(self, msg, toUserName=None, mediaId=None):
|
||||
''' wrapped function for all the sending functions
|
||||
for options
|
||||
- msg: message starts with different string indicates different type
|
||||
- list of type string: ['@fil@', '@img@', '@msg@', '@vid@']
|
||||
- they are for file, image, plain text, video
|
||||
- if none of them matches, it will be sent like plain text
|
||||
- toUserName: 'UserName' key of friend dict
|
||||
- mediaId: if set, uploading will not be repeated
|
||||
it is defined in components/messages.py
|
||||
'''
|
||||
raise NotImplementedError()
|
||||
def revoke(self, msgId, toUserName, localId=None):
|
||||
''' revoke message with its and msgId
|
||||
for options
|
||||
- msgId: message Id on server
|
||||
- toUserName: 'UserName' key of friend dict
|
||||
- localId: message Id at local (optional)
|
||||
it is defined in components/messages.py
|
||||
'''
|
||||
raise NotImplementedError()
|
||||
def dump_login_status(self, fileDir=None):
|
||||
''' dump login status to a specific file
|
||||
for option
|
||||
- fileDir: dir for dumping login status
|
||||
it is defined in components/hotreload.py
|
||||
'''
|
||||
raise NotImplementedError()
|
||||
def load_login_status(self, fileDir,
|
||||
loginCallback=None, exitCallback=None):
|
||||
''' load login status from a specific file
|
||||
for option
|
||||
- fileDir: file for loading login status
|
||||
- loginCallback: callback after successfully logged in
|
||||
- if not set, screen is cleared and qrcode is deleted
|
||||
- exitCallback: callback after logged out
|
||||
- it contains calling of logout
|
||||
it is defined in components/hotreload.py
|
||||
'''
|
||||
raise NotImplementedError()
|
||||
def auto_login(self, hotReload=False, statusStorageDir='itchat.pkl',
|
||||
enableCmdQR=False, picDir=None, qrCallback=None,
|
||||
loginCallback=None, exitCallback=None):
|
||||
''' log in like web wechat does
|
||||
for log in
|
||||
- a QR code will be downloaded and opened
|
||||
- then scanning status is logged, it paused for you confirm
|
||||
- finally it logged in and show your nickName
|
||||
for options
|
||||
- hotReload: enable hot reload
|
||||
- statusStorageDir: dir for storing log in status
|
||||
- enableCmdQR: show qrcode in command line
|
||||
- integers can be used to fit strange char length
|
||||
- picDir: place for storing qrcode
|
||||
- loginCallback: callback after successfully logged in
|
||||
- if not set, screen is cleared and qrcode is deleted
|
||||
- exitCallback: callback after logged out
|
||||
- it contains calling of logout
|
||||
- qrCallback: method that should accept uuid, status, qrcode
|
||||
for usage
|
||||
..code::python
|
||||
|
||||
import itchat
|
||||
itchat.auto_login()
|
||||
|
||||
it is defined in components/register.py
|
||||
and of course every single move in login can be called outside
|
||||
- you may scan source code to see how
|
||||
- and modified according to your own demond
|
||||
'''
|
||||
raise NotImplementedError()
|
||||
def configured_reply(self):
|
||||
''' determine the type of message and reply if its method is defined
|
||||
however, I use a strange way to determine whether a msg is from massive platform
|
||||
I haven't found a better solution here
|
||||
The main problem I'm worrying about is the mismatching of new friends added on phone
|
||||
If you have any good idea, pleeeease report an issue. I will be more than grateful.
|
||||
'''
|
||||
raise NotImplementedError()
|
||||
def msg_register(self, msgType,
|
||||
isFriendChat=False, isGroupChat=False, isMpChat=False):
|
||||
''' a decorator constructor
|
||||
return a specific decorator based on information given
|
||||
'''
|
||||
raise NotImplementedError()
|
||||
def run(self, debug=True, blockThread=True):
|
||||
''' start auto respond
|
||||
for option
|
||||
- debug: if set, debug info will be shown on screen
|
||||
it is defined in components/register.py
|
||||
'''
|
||||
raise NotImplementedError()
|
||||
def search_friends(self, name=None, userName=None, remarkName=None, nickName=None,
|
||||
wechatAccount=None):
|
||||
return self.storageClass.search_friends(name, userName, remarkName,
|
||||
nickName, wechatAccount)
|
||||
def search_chatrooms(self, name=None, userName=None):
|
||||
return self.storageClass.search_chatrooms(name, userName)
|
||||
def search_mps(self, name=None, userName=None):
|
||||
return self.storageClass.search_mps(name, userName)
|
||||
36
lib/itchat/log.py
Normal file
36
lib/itchat/log.py
Normal file
@@ -0,0 +1,36 @@
|
||||
import logging
|
||||
|
||||
class LogSystem(object):
|
||||
handlerList = []
|
||||
showOnCmd = True
|
||||
loggingLevel = logging.INFO
|
||||
loggingFile = None
|
||||
def __init__(self):
|
||||
self.logger = logging.getLogger('itchat')
|
||||
self.logger.addHandler(logging.NullHandler())
|
||||
self.logger.setLevel(self.loggingLevel)
|
||||
self.cmdHandler = logging.StreamHandler()
|
||||
self.fileHandler = None
|
||||
self.logger.addHandler(self.cmdHandler)
|
||||
def set_logging(self, showOnCmd=True, loggingFile=None,
|
||||
loggingLevel=logging.INFO):
|
||||
if showOnCmd != self.showOnCmd:
|
||||
if showOnCmd:
|
||||
self.logger.addHandler(self.cmdHandler)
|
||||
else:
|
||||
self.logger.removeHandler(self.cmdHandler)
|
||||
self.showOnCmd = showOnCmd
|
||||
if loggingFile != self.loggingFile:
|
||||
if self.loggingFile is not None: # clear old fileHandler
|
||||
self.logger.removeHandler(self.fileHandler)
|
||||
self.fileHandler.close()
|
||||
if loggingFile is not None: # add new fileHandler
|
||||
self.fileHandler = logging.FileHandler(loggingFile)
|
||||
self.logger.addHandler(self.fileHandler)
|
||||
self.loggingFile = loggingFile
|
||||
if loggingLevel != self.loggingLevel:
|
||||
self.logger.setLevel(loggingLevel)
|
||||
self.loggingLevel = loggingLevel
|
||||
|
||||
ls = LogSystem()
|
||||
set_logging = ls.set_logging
|
||||
67
lib/itchat/returnvalues.py
Normal file
67
lib/itchat/returnvalues.py
Normal file
@@ -0,0 +1,67 @@
|
||||
#coding=utf8
|
||||
TRANSLATE = 'Chinese'
|
||||
|
||||
class ReturnValue(dict):
|
||||
''' turn return value of itchat into a boolean value
|
||||
for requests:
|
||||
..code::python
|
||||
|
||||
import requests
|
||||
r = requests.get('http://httpbin.org/get')
|
||||
print(ReturnValue(rawResponse=r)
|
||||
|
||||
for normal dict:
|
||||
..code::python
|
||||
|
||||
returnDict = {
|
||||
'BaseResponse': {
|
||||
'Ret': 0,
|
||||
'ErrMsg': 'My error msg', }, }
|
||||
print(ReturnValue(returnDict))
|
||||
'''
|
||||
def __init__(self, returnValueDict={}, rawResponse=None):
|
||||
if rawResponse:
|
||||
try:
|
||||
returnValueDict = rawResponse.json()
|
||||
except ValueError:
|
||||
returnValueDict = {
|
||||
'BaseResponse': {
|
||||
'Ret': -1004,
|
||||
'ErrMsg': 'Unexpected return value', },
|
||||
'Data': rawResponse.content, }
|
||||
for k, v in returnValueDict.items():
|
||||
self[k] = v
|
||||
if not 'BaseResponse' in self:
|
||||
self['BaseResponse'] = {
|
||||
'ErrMsg': 'no BaseResponse in raw response',
|
||||
'Ret': -1000, }
|
||||
if TRANSLATE:
|
||||
self['BaseResponse']['RawMsg'] = self['BaseResponse'].get('ErrMsg', '')
|
||||
self['BaseResponse']['ErrMsg'] = \
|
||||
TRANSLATION[TRANSLATE].get(
|
||||
self['BaseResponse'].get('Ret', '')) \
|
||||
or self['BaseResponse'].get('ErrMsg', u'No ErrMsg')
|
||||
self['BaseResponse']['RawMsg'] = \
|
||||
self['BaseResponse']['RawMsg'] or self['BaseResponse']['ErrMsg']
|
||||
def __nonzero__(self):
|
||||
return self['BaseResponse'].get('Ret') == 0
|
||||
def __bool__(self):
|
||||
return self.__nonzero__()
|
||||
def __str__(self):
|
||||
return '{%s}' % ', '.join(
|
||||
['%s: %s' % (repr(k),repr(v)) for k,v in self.items()])
|
||||
def __repr__(self):
|
||||
return '<ItchatReturnValue: %s>' % self.__str__()
|
||||
|
||||
TRANSLATION = {
|
||||
'Chinese': {
|
||||
-1000: u'返回值不带BaseResponse',
|
||||
-1001: u'无法找到对应的成员',
|
||||
-1002: u'文件位置错误',
|
||||
-1003: u'服务器拒绝连接',
|
||||
-1004: u'服务器返回异常值',
|
||||
-1005: u'参数错误',
|
||||
-1006: u'无效操作',
|
||||
0: u'请求成功',
|
||||
},
|
||||
}
|
||||
117
lib/itchat/storage/__init__.py
Normal file
117
lib/itchat/storage/__init__.py
Normal file
@@ -0,0 +1,117 @@
|
||||
import os, time, copy
|
||||
from threading import Lock
|
||||
|
||||
from .messagequeue import Queue
|
||||
from .templates import (
|
||||
ContactList, AbstractUserDict, User,
|
||||
MassivePlatform, Chatroom, ChatroomMember)
|
||||
|
||||
def contact_change(fn):
|
||||
def _contact_change(core, *args, **kwargs):
|
||||
with core.storageClass.updateLock:
|
||||
return fn(core, *args, **kwargs)
|
||||
return _contact_change
|
||||
|
||||
class Storage(object):
|
||||
def __init__(self, core):
|
||||
self.userName = None
|
||||
self.nickName = None
|
||||
self.updateLock = Lock()
|
||||
self.memberList = ContactList()
|
||||
self.mpList = ContactList()
|
||||
self.chatroomList = ContactList()
|
||||
self.msgList = Queue(-1)
|
||||
self.lastInputUserName = None
|
||||
self.memberList.set_default_value(contactClass=User)
|
||||
self.memberList.core = core
|
||||
self.mpList.set_default_value(contactClass=MassivePlatform)
|
||||
self.mpList.core = core
|
||||
self.chatroomList.set_default_value(contactClass=Chatroom)
|
||||
self.chatroomList.core = core
|
||||
def dumps(self):
|
||||
return {
|
||||
'userName' : self.userName,
|
||||
'nickName' : self.nickName,
|
||||
'memberList' : self.memberList,
|
||||
'mpList' : self.mpList,
|
||||
'chatroomList' : self.chatroomList,
|
||||
'lastInputUserName' : self.lastInputUserName, }
|
||||
def loads(self, j):
|
||||
self.userName = j.get('userName', None)
|
||||
self.nickName = j.get('nickName', None)
|
||||
del self.memberList[:]
|
||||
for i in j.get('memberList', []):
|
||||
self.memberList.append(i)
|
||||
del self.mpList[:]
|
||||
for i in j.get('mpList', []):
|
||||
self.mpList.append(i)
|
||||
del self.chatroomList[:]
|
||||
for i in j.get('chatroomList', []):
|
||||
self.chatroomList.append(i)
|
||||
# I tried to solve everything in pickle
|
||||
# but this way is easier and more storage-saving
|
||||
for chatroom in self.chatroomList:
|
||||
if 'MemberList' in chatroom:
|
||||
for member in chatroom['MemberList']:
|
||||
member.core = chatroom.core
|
||||
member.chatroom = chatroom
|
||||
if 'Self' in chatroom:
|
||||
chatroom['Self'].core = chatroom.core
|
||||
chatroom['Self'].chatroom = chatroom
|
||||
self.lastInputUserName = j.get('lastInputUserName', None)
|
||||
def search_friends(self, name=None, userName=None, remarkName=None, nickName=None,
|
||||
wechatAccount=None):
|
||||
with self.updateLock:
|
||||
if (name or userName or remarkName or nickName or wechatAccount) is None:
|
||||
return copy.deepcopy(self.memberList[0]) # my own account
|
||||
elif userName: # return the only userName match
|
||||
for m in self.memberList:
|
||||
if m['UserName'] == userName:
|
||||
return copy.deepcopy(m)
|
||||
else:
|
||||
matchDict = {
|
||||
'RemarkName' : remarkName,
|
||||
'NickName' : nickName,
|
||||
'Alias' : wechatAccount, }
|
||||
for k in ('RemarkName', 'NickName', 'Alias'):
|
||||
if matchDict[k] is None:
|
||||
del matchDict[k]
|
||||
if name: # select based on name
|
||||
contact = []
|
||||
for m in self.memberList:
|
||||
if any([m.get(k) == name for k in ('RemarkName', 'NickName', 'Alias')]):
|
||||
contact.append(m)
|
||||
else:
|
||||
contact = self.memberList[:]
|
||||
if matchDict: # select again based on matchDict
|
||||
friendList = []
|
||||
for m in contact:
|
||||
if all([m.get(k) == v for k, v in matchDict.items()]):
|
||||
friendList.append(m)
|
||||
return copy.deepcopy(friendList)
|
||||
else:
|
||||
return copy.deepcopy(contact)
|
||||
def search_chatrooms(self, name=None, userName=None):
|
||||
with self.updateLock:
|
||||
if userName is not None:
|
||||
for m in self.chatroomList:
|
||||
if m['UserName'] == userName:
|
||||
return copy.deepcopy(m)
|
||||
elif name is not None:
|
||||
matchList = []
|
||||
for m in self.chatroomList:
|
||||
if name in m['NickName']:
|
||||
matchList.append(copy.deepcopy(m))
|
||||
return matchList
|
||||
def search_mps(self, name=None, userName=None):
|
||||
with self.updateLock:
|
||||
if userName is not None:
|
||||
for m in self.mpList:
|
||||
if m['UserName'] == userName:
|
||||
return copy.deepcopy(m)
|
||||
elif name is not None:
|
||||
matchList = []
|
||||
for m in self.mpList:
|
||||
if name in m['NickName']:
|
||||
matchList.append(copy.deepcopy(m))
|
||||
return matchList
|
||||
32
lib/itchat/storage/messagequeue.py
Normal file
32
lib/itchat/storage/messagequeue.py
Normal file
@@ -0,0 +1,32 @@
|
||||
import logging
|
||||
try:
|
||||
import Queue as queue
|
||||
except ImportError:
|
||||
import queue
|
||||
|
||||
from .templates import AttributeDict
|
||||
|
||||
logger = logging.getLogger('itchat')
|
||||
|
||||
class Queue(queue.Queue):
|
||||
def put(self, message):
|
||||
queue.Queue.put(self, Message(message))
|
||||
|
||||
class Message(AttributeDict):
|
||||
def download(self, fileName):
|
||||
if hasattr(self.text, '__call__'):
|
||||
return self.text(fileName)
|
||||
else:
|
||||
return b''
|
||||
def __getitem__(self, value):
|
||||
if value in ('isAdmin', 'isAt'):
|
||||
v = value[0].upper() + value[1:] # ''[1:] == ''
|
||||
logger.debug('%s is expired in 1.3.0, use %s instead.' % (value, v))
|
||||
value = v
|
||||
return super(Message, self).__getitem__(value)
|
||||
def __str__(self):
|
||||
return '{%s}' % ', '.join(
|
||||
['%s: %s' % (repr(k),repr(v)) for k,v in self.items()])
|
||||
def __repr__(self):
|
||||
return '<%s: %s>' % (self.__class__.__name__.split('.')[-1],
|
||||
self.__str__())
|
||||
318
lib/itchat/storage/templates.py
Normal file
318
lib/itchat/storage/templates.py
Normal file
@@ -0,0 +1,318 @@
|
||||
import logging, copy, pickle
|
||||
from weakref import ref
|
||||
|
||||
from ..returnvalues import ReturnValue
|
||||
from ..utils import update_info_dict
|
||||
|
||||
logger = logging.getLogger('itchat')
|
||||
|
||||
class AttributeDict(dict):
|
||||
def __getattr__(self, value):
|
||||
keyName = value[0].upper() + value[1:]
|
||||
try:
|
||||
return self[keyName]
|
||||
except KeyError:
|
||||
raise AttributeError("'%s' object has no attribute '%s'" % (
|
||||
self.__class__.__name__.split('.')[-1], keyName))
|
||||
def get(self, v, d=None):
|
||||
try:
|
||||
return self[v]
|
||||
except KeyError:
|
||||
return d
|
||||
|
||||
class UnInitializedItchat(object):
|
||||
def _raise_error(self, *args, **kwargs):
|
||||
logger.warning('An itchat instance is called before initialized')
|
||||
def __getattr__(self, value):
|
||||
return self._raise_error
|
||||
|
||||
class ContactList(list):
|
||||
''' when a dict is append, init function will be called to format that dict '''
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(ContactList, self).__init__(*args, **kwargs)
|
||||
self.__setstate__(None)
|
||||
@property
|
||||
def core(self):
|
||||
return getattr(self, '_core', lambda: fakeItchat)() or fakeItchat
|
||||
@core.setter
|
||||
def core(self, value):
|
||||
self._core = ref(value)
|
||||
def set_default_value(self, initFunction=None, contactClass=None):
|
||||
if hasattr(initFunction, '__call__'):
|
||||
self.contactInitFn = initFunction
|
||||
if hasattr(contactClass, '__call__'):
|
||||
self.contactClass = contactClass
|
||||
def append(self, value):
|
||||
contact = self.contactClass(value)
|
||||
contact.core = self.core
|
||||
if self.contactInitFn is not None:
|
||||
contact = self.contactInitFn(self, contact) or contact
|
||||
super(ContactList, self).append(contact)
|
||||
def __deepcopy__(self, memo):
|
||||
r = self.__class__([copy.deepcopy(v) for v in self])
|
||||
r.contactInitFn = self.contactInitFn
|
||||
r.contactClass = self.contactClass
|
||||
r.core = self.core
|
||||
return r
|
||||
def __getstate__(self):
|
||||
return 1
|
||||
def __setstate__(self, state):
|
||||
self.contactInitFn = None
|
||||
self.contactClass = User
|
||||
def __str__(self):
|
||||
return '[%s]' % ', '.join([repr(v) for v in self])
|
||||
def __repr__(self):
|
||||
return '<%s: %s>' % (self.__class__.__name__.split('.')[-1],
|
||||
self.__str__())
|
||||
|
||||
class AbstractUserDict(AttributeDict):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(AbstractUserDict, self).__init__(*args, **kwargs)
|
||||
@property
|
||||
def core(self):
|
||||
return getattr(self, '_core', lambda: fakeItchat)() or fakeItchat
|
||||
@core.setter
|
||||
def core(self, value):
|
||||
self._core = ref(value)
|
||||
def update(self):
|
||||
return ReturnValue({'BaseResponse': {
|
||||
'Ret': -1006,
|
||||
'ErrMsg': '%s can not be updated' % \
|
||||
self.__class__.__name__, }, })
|
||||
def set_alias(self, alias):
|
||||
return ReturnValue({'BaseResponse': {
|
||||
'Ret': -1006,
|
||||
'ErrMsg': '%s can not set alias' % \
|
||||
self.__class__.__name__, }, })
|
||||
def set_pinned(self, isPinned=True):
|
||||
return ReturnValue({'BaseResponse': {
|
||||
'Ret': -1006,
|
||||
'ErrMsg': '%s can not be pinned' % \
|
||||
self.__class__.__name__, }, })
|
||||
def verify(self):
|
||||
return ReturnValue({'BaseResponse': {
|
||||
'Ret': -1006,
|
||||
'ErrMsg': '%s do not need verify' % \
|
||||
self.__class__.__name__, }, })
|
||||
def get_head_image(self, imageDir=None):
|
||||
return self.core.get_head_img(self.userName, picDir=imageDir)
|
||||
def delete_member(self, userName):
|
||||
return ReturnValue({'BaseResponse': {
|
||||
'Ret': -1006,
|
||||
'ErrMsg': '%s can not delete member' % \
|
||||
self.__class__.__name__, }, })
|
||||
def add_member(self, userName):
|
||||
return ReturnValue({'BaseResponse': {
|
||||
'Ret': -1006,
|
||||
'ErrMsg': '%s can not add member' % \
|
||||
self.__class__.__name__, }, })
|
||||
def send_raw_msg(self, msgType, content):
|
||||
return self.core.send_raw_msg(msgType, content, self.userName)
|
||||
def send_msg(self, msg='Test Message'):
|
||||
return self.core.send_msg(msg, self.userName)
|
||||
def send_file(self, fileDir, mediaId=None):
|
||||
return self.core.send_file(fileDir, self.userName, mediaId)
|
||||
def send_image(self, fileDir, mediaId=None):
|
||||
return self.core.send_image(fileDir, self.userName, mediaId)
|
||||
def send_video(self, fileDir=None, mediaId=None):
|
||||
return self.core.send_video(fileDir, self.userName, mediaId)
|
||||
def send(self, msg, mediaId=None):
|
||||
return self.core.send(msg, self.userName, mediaId)
|
||||
def search_member(self, name=None, userName=None, remarkName=None, nickName=None,
|
||||
wechatAccount=None):
|
||||
return ReturnValue({'BaseResponse': {
|
||||
'Ret': -1006,
|
||||
'ErrMsg': '%s do not have members' % \
|
||||
self.__class__.__name__, }, })
|
||||
def __deepcopy__(self, memo):
|
||||
r = self.__class__()
|
||||
for k, v in self.items():
|
||||
r[copy.deepcopy(k)] = copy.deepcopy(v)
|
||||
r.core = self.core
|
||||
return r
|
||||
def __str__(self):
|
||||
return '{%s}' % ', '.join(
|
||||
['%s: %s' % (repr(k),repr(v)) for k,v in self.items()])
|
||||
def __repr__(self):
|
||||
return '<%s: %s>' % (self.__class__.__name__.split('.')[-1],
|
||||
self.__str__())
|
||||
def __getstate__(self):
|
||||
return 1
|
||||
def __setstate__(self, state):
|
||||
pass
|
||||
|
||||
class User(AbstractUserDict):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(User, self).__init__(*args, **kwargs)
|
||||
self.__setstate__(None)
|
||||
def update(self):
|
||||
r = self.core.update_friend(self.userName)
|
||||
if r:
|
||||
update_info_dict(self, r)
|
||||
return r
|
||||
def set_alias(self, alias):
|
||||
return self.core.set_alias(self.userName, alias)
|
||||
def set_pinned(self, isPinned=True):
|
||||
return self.core.set_pinned(self.userName, isPinned)
|
||||
def verify(self):
|
||||
return self.core.add_friend(**self.verifyDict)
|
||||
def __deepcopy__(self, memo):
|
||||
r = super(User, self).__deepcopy__(memo)
|
||||
r.verifyDict = copy.deepcopy(self.verifyDict)
|
||||
return r
|
||||
def __setstate__(self, state):
|
||||
super(User, self).__setstate__(state)
|
||||
self.verifyDict = {}
|
||||
self['MemberList'] = fakeContactList
|
||||
|
||||
class MassivePlatform(AbstractUserDict):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(MassivePlatform, self).__init__(*args, **kwargs)
|
||||
self.__setstate__(None)
|
||||
def __setstate__(self, state):
|
||||
super(MassivePlatform, self).__setstate__(state)
|
||||
self['MemberList'] = fakeContactList
|
||||
|
||||
class Chatroom(AbstractUserDict):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(Chatroom, self).__init__(*args, **kwargs)
|
||||
memberList = ContactList()
|
||||
userName = self.get('UserName', '')
|
||||
refSelf = ref(self)
|
||||
def init_fn(parentList, d):
|
||||
d.chatroom = refSelf() or \
|
||||
parentList.core.search_chatrooms(userName=userName)
|
||||
memberList.set_default_value(init_fn, ChatroomMember)
|
||||
if 'MemberList' in self:
|
||||
for member in self.memberList:
|
||||
memberList.append(member)
|
||||
self['MemberList'] = memberList
|
||||
@property
|
||||
def core(self):
|
||||
return getattr(self, '_core', lambda: fakeItchat)() or fakeItchat
|
||||
@core.setter
|
||||
def core(self, value):
|
||||
self._core = ref(value)
|
||||
self.memberList.core = value
|
||||
for member in self.memberList:
|
||||
member.core = value
|
||||
def update(self, detailedMember=False):
|
||||
r = self.core.update_chatroom(self.userName, detailedMember)
|
||||
if r:
|
||||
update_info_dict(self, r)
|
||||
self['MemberList'] = r['MemberList']
|
||||
return r
|
||||
def set_alias(self, alias):
|
||||
return self.core.set_chatroom_name(self.userName, alias)
|
||||
def set_pinned(self, isPinned=True):
|
||||
return self.core.set_pinned(self.userName, isPinned)
|
||||
def delete_member(self, userName):
|
||||
return self.core.delete_member_from_chatroom(self.userName, userName)
|
||||
def add_member(self, userName):
|
||||
return self.core.add_member_into_chatroom(self.userName, userName)
|
||||
def search_member(self, name=None, userName=None, remarkName=None, nickName=None,
|
||||
wechatAccount=None):
|
||||
with self.core.storageClass.updateLock:
|
||||
if (name or userName or remarkName or nickName or wechatAccount) is None:
|
||||
return None
|
||||
elif userName: # return the only userName match
|
||||
for m in self.memberList:
|
||||
if m.userName == userName:
|
||||
return copy.deepcopy(m)
|
||||
else:
|
||||
matchDict = {
|
||||
'RemarkName' : remarkName,
|
||||
'NickName' : nickName,
|
||||
'Alias' : wechatAccount, }
|
||||
for k in ('RemarkName', 'NickName', 'Alias'):
|
||||
if matchDict[k] is None:
|
||||
del matchDict[k]
|
||||
if name: # select based on name
|
||||
contact = []
|
||||
for m in self.memberList:
|
||||
if any([m.get(k) == name for k in ('RemarkName', 'NickName', 'Alias')]):
|
||||
contact.append(m)
|
||||
else:
|
||||
contact = self.memberList[:]
|
||||
if matchDict: # select again based on matchDict
|
||||
friendList = []
|
||||
for m in contact:
|
||||
if all([m.get(k) == v for k, v in matchDict.items()]):
|
||||
friendList.append(m)
|
||||
return copy.deepcopy(friendList)
|
||||
else:
|
||||
return copy.deepcopy(contact)
|
||||
def __setstate__(self, state):
|
||||
super(Chatroom, self).__setstate__(state)
|
||||
if not 'MemberList' in self:
|
||||
self['MemberList'] = fakeContactList
|
||||
|
||||
class ChatroomMember(AbstractUserDict):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(AbstractUserDict, self).__init__(*args, **kwargs)
|
||||
self.__setstate__(None)
|
||||
@property
|
||||
def chatroom(self):
|
||||
r = getattr(self, '_chatroom', lambda: fakeChatroom)()
|
||||
if r is None:
|
||||
userName = getattr(self, '_chatroomUserName', '')
|
||||
r = self.core.search_chatrooms(userName=userName)
|
||||
if isinstance(r, dict):
|
||||
self.chatroom = r
|
||||
return r or fakeChatroom
|
||||
@chatroom.setter
|
||||
def chatroom(self, value):
|
||||
if isinstance(value, dict) and 'UserName' in value:
|
||||
self._chatroom = ref(value)
|
||||
self._chatroomUserName = value['UserName']
|
||||
def get_head_image(self, imageDir=None):
|
||||
return self.core.get_head_img(self.userName, self.chatroom.userName, picDir=imageDir)
|
||||
def delete_member(self, userName):
|
||||
return self.core.delete_member_from_chatroom(self.chatroom.userName, self.userName)
|
||||
def send_raw_msg(self, msgType, content):
|
||||
return ReturnValue({'BaseResponse': {
|
||||
'Ret': -1006,
|
||||
'ErrMsg': '%s can not send message directly' % \
|
||||
self.__class__.__name__, }, })
|
||||
def send_msg(self, msg='Test Message'):
|
||||
return ReturnValue({'BaseResponse': {
|
||||
'Ret': -1006,
|
||||
'ErrMsg': '%s can not send message directly' % \
|
||||
self.__class__.__name__, }, })
|
||||
def send_file(self, fileDir, mediaId=None):
|
||||
return ReturnValue({'BaseResponse': {
|
||||
'Ret': -1006,
|
||||
'ErrMsg': '%s can not send message directly' % \
|
||||
self.__class__.__name__, }, })
|
||||
def send_image(self, fileDir, mediaId=None):
|
||||
return ReturnValue({'BaseResponse': {
|
||||
'Ret': -1006,
|
||||
'ErrMsg': '%s can not send message directly' % \
|
||||
self.__class__.__name__, }, })
|
||||
def send_video(self, fileDir=None, mediaId=None):
|
||||
return ReturnValue({'BaseResponse': {
|
||||
'Ret': -1006,
|
||||
'ErrMsg': '%s can not send message directly' % \
|
||||
self.__class__.__name__, }, })
|
||||
def send(self, msg, mediaId=None):
|
||||
return ReturnValue({'BaseResponse': {
|
||||
'Ret': -1006,
|
||||
'ErrMsg': '%s can not send message directly' % \
|
||||
self.__class__.__name__, }, })
|
||||
def __setstate__(self, state):
|
||||
super(ChatroomMember, self).__setstate__(state)
|
||||
self['MemberList'] = fakeContactList
|
||||
|
||||
def wrap_user_dict(d):
|
||||
userName = d.get('UserName')
|
||||
if '@@' in userName:
|
||||
r = Chatroom(d)
|
||||
elif d.get('VerifyFlag', 8) & 8 == 0:
|
||||
r = User(d)
|
||||
else:
|
||||
r = MassivePlatform(d)
|
||||
return r
|
||||
|
||||
fakeItchat = UnInitializedItchat()
|
||||
fakeContactList = ContactList()
|
||||
fakeChatroom = Chatroom()
|
||||
163
lib/itchat/utils.py
Normal file
163
lib/itchat/utils.py
Normal file
@@ -0,0 +1,163 @@
|
||||
import re, os, sys, subprocess, copy, traceback, logging
|
||||
|
||||
try:
|
||||
from HTMLParser import HTMLParser
|
||||
except ImportError:
|
||||
from html.parser import HTMLParser
|
||||
try:
|
||||
from urllib import quote as _quote
|
||||
quote = lambda n: _quote(n.encode('utf8', 'replace'))
|
||||
except ImportError:
|
||||
from urllib.parse import quote
|
||||
|
||||
import requests
|
||||
|
||||
from . import config
|
||||
|
||||
logger = logging.getLogger('itchat')
|
||||
|
||||
emojiRegex = re.compile(r'<span class="emoji emoji(.{1,10})"></span>')
|
||||
htmlParser = HTMLParser()
|
||||
if not hasattr(htmlParser, 'unescape'):
|
||||
import html
|
||||
htmlParser.unescape = html.unescape
|
||||
# FIX Python 3.9 HTMLParser.unescape is removed. See https://docs.python.org/3.9/whatsnew/3.9.html
|
||||
try:
|
||||
b = u'\u2588'
|
||||
sys.stdout.write(b + '\r')
|
||||
sys.stdout.flush()
|
||||
except UnicodeEncodeError:
|
||||
BLOCK = 'MM'
|
||||
else:
|
||||
BLOCK = b
|
||||
friendInfoTemplate = {}
|
||||
for k in ('UserName', 'City', 'DisplayName', 'PYQuanPin', 'RemarkPYInitial', 'Province',
|
||||
'KeyWord', 'RemarkName', 'PYInitial', 'EncryChatRoomId', 'Alias', 'Signature',
|
||||
'NickName', 'RemarkPYQuanPin', 'HeadImgUrl'):
|
||||
friendInfoTemplate[k] = ''
|
||||
for k in ('UniFriend', 'Sex', 'AppAccountFlag', 'VerifyFlag', 'ChatRoomId', 'HideInputBarFlag',
|
||||
'AttrStatus', 'SnsFlag', 'MemberCount', 'OwnerUin', 'ContactFlag', 'Uin',
|
||||
'StarFriend', 'Statues'):
|
||||
friendInfoTemplate[k] = 0
|
||||
friendInfoTemplate['MemberList'] = []
|
||||
|
||||
def clear_screen():
|
||||
os.system('cls' if config.OS == 'Windows' else 'clear')
|
||||
|
||||
def emoji_formatter(d, k):
|
||||
''' _emoji_deebugger is for bugs about emoji match caused by wechat backstage
|
||||
like :face with tears of joy: will be replaced with :cat face with tears of joy:
|
||||
'''
|
||||
def _emoji_debugger(d, k):
|
||||
s = d[k].replace('<span class="emoji emoji1f450"></span',
|
||||
'<span class="emoji emoji1f450"></span>') # fix missing bug
|
||||
def __fix_miss_match(m):
|
||||
return '<span class="emoji emoji%s"></span>' % ({
|
||||
'1f63c': '1f601', '1f639': '1f602', '1f63a': '1f603',
|
||||
'1f4ab': '1f616', '1f64d': '1f614', '1f63b': '1f60d',
|
||||
'1f63d': '1f618', '1f64e': '1f621', '1f63f': '1f622',
|
||||
}.get(m.group(1), m.group(1)))
|
||||
return emojiRegex.sub(__fix_miss_match, s)
|
||||
def _emoji_formatter(m):
|
||||
s = m.group(1)
|
||||
if len(s) == 6:
|
||||
return ('\\U%s\\U%s'%(s[:2].rjust(8, '0'), s[2:].rjust(8, '0'))
|
||||
).encode('utf8').decode('unicode-escape', 'replace')
|
||||
elif len(s) == 10:
|
||||
return ('\\U%s\\U%s'%(s[:5].rjust(8, '0'), s[5:].rjust(8, '0'))
|
||||
).encode('utf8').decode('unicode-escape', 'replace')
|
||||
else:
|
||||
return ('\\U%s'%m.group(1).rjust(8, '0')
|
||||
).encode('utf8').decode('unicode-escape', 'replace')
|
||||
d[k] = _emoji_debugger(d, k)
|
||||
d[k] = emojiRegex.sub(_emoji_formatter, d[k])
|
||||
|
||||
def msg_formatter(d, k):
|
||||
emoji_formatter(d, k)
|
||||
d[k] = d[k].replace('<br/>', '\n')
|
||||
d[k] = htmlParser.unescape(d[k])
|
||||
|
||||
def check_file(fileDir):
|
||||
try:
|
||||
with open(fileDir):
|
||||
pass
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
|
||||
def print_qr(fileDir):
|
||||
if config.OS == 'Darwin':
|
||||
subprocess.call(['open', fileDir])
|
||||
elif config.OS == 'Linux':
|
||||
subprocess.call(['xdg-open', fileDir])
|
||||
else:
|
||||
os.startfile(fileDir)
|
||||
|
||||
def print_cmd_qr(qrText, white=BLOCK, black=' ', enableCmdQR=True):
|
||||
blockCount = int(enableCmdQR)
|
||||
if abs(blockCount) == 0:
|
||||
blockCount = 1
|
||||
white *= abs(blockCount)
|
||||
if blockCount < 0:
|
||||
white, black = black, white
|
||||
sys.stdout.write(' '*50 + '\r')
|
||||
sys.stdout.flush()
|
||||
qr = qrText.replace('0', white).replace('1', black)
|
||||
sys.stdout.write(qr)
|
||||
sys.stdout.flush()
|
||||
|
||||
def struct_friend_info(knownInfo):
|
||||
member = copy.deepcopy(friendInfoTemplate)
|
||||
for k, v in copy.deepcopy(knownInfo).items(): member[k] = v
|
||||
return member
|
||||
|
||||
def search_dict_list(l, key, value):
|
||||
''' Search a list of dict
|
||||
* return dict with specific value & key '''
|
||||
for i in l:
|
||||
if i.get(key) == value:
|
||||
return i
|
||||
|
||||
def print_line(msg, oneLine = False):
|
||||
if oneLine:
|
||||
sys.stdout.write(' '*40 + '\r')
|
||||
sys.stdout.flush()
|
||||
else:
|
||||
sys.stdout.write('\n')
|
||||
sys.stdout.write(msg.encode(sys.stdin.encoding or 'utf8', 'replace'
|
||||
).decode(sys.stdin.encoding or 'utf8', 'replace'))
|
||||
sys.stdout.flush()
|
||||
|
||||
def test_connect(retryTime=5):
|
||||
for i in range(retryTime):
|
||||
try:
|
||||
r = requests.get(config.BASE_URL)
|
||||
return True
|
||||
except:
|
||||
if i == retryTime - 1:
|
||||
logger.error(traceback.format_exc())
|
||||
return False
|
||||
|
||||
def contact_deep_copy(core, contact):
|
||||
with core.storageClass.updateLock:
|
||||
return copy.deepcopy(contact)
|
||||
|
||||
def get_image_postfix(data):
|
||||
data = data[:20]
|
||||
if b'GIF' in data:
|
||||
return 'gif'
|
||||
elif b'PNG' in data:
|
||||
return 'png'
|
||||
elif b'JFIF' in data:
|
||||
return 'jpg'
|
||||
return ''
|
||||
|
||||
def update_info_dict(oldInfoDict, newInfoDict):
|
||||
''' only normal values will be updated here
|
||||
because newInfoDict is normal dict, so it's not necessary to consider templates
|
||||
'''
|
||||
for k, v in newInfoDict.items():
|
||||
if any((isinstance(v, t) for t in (tuple, list, dict))):
|
||||
pass # these values will be updated somewhere else
|
||||
elif oldInfoDict.get(k) is None or v not in (None, '', '0', 0):
|
||||
oldInfoDict[k] = v
|
||||
7
nixpacks.toml
Normal file
7
nixpacks.toml
Normal file
@@ -0,0 +1,7 @@
|
||||
providers = ['python']
|
||||
|
||||
[phases.setup]
|
||||
nixPkgs = ['python310']
|
||||
cmds = ['apt-get update','apt-get install -y --no-install-recommends ffmpeg espeak','python -m venv /opt/venv && . /opt/venv/bin/activate && pip install -r requirements-optional.txt']
|
||||
[start]
|
||||
cmd = "python ./app.py"
|
||||
273
plugins/README.md
Normal file
273
plugins/README.md
Normal file
@@ -0,0 +1,273 @@
|
||||
**Table of Content**
|
||||
|
||||
- [插件化初衷](#插件化初衷)
|
||||
- [插件安装方法](#插件安装方法)
|
||||
- [插件化实现](#插件化实现)
|
||||
- [插件编写示例](#插件编写示例)
|
||||
- [插件设计建议](#插件设计建议)
|
||||
|
||||
## 插件化初衷
|
||||
|
||||
之前未插件化的代码耦合程度高,如果要定制一些个性化功能(如流量控制、接入`NovelAI`画图平台等),需要了解代码主体,避免影响到其他的功能。多个功能同时存在时,无法调整功能的优先级顺序,功能配置项也非常混乱。
|
||||
|
||||
此时插件化应声而出。
|
||||
|
||||
**插件化**: 在保证主体功能是ChatGPT的前提下,我们推荐将主体功能外的功能利用插件的方式实现。
|
||||
|
||||
- [x] 可根据功能需要,下载不同插件。
|
||||
- [x] 插件开发成本低,仅需了解插件触发事件,并按照插件定义接口编写插件。
|
||||
- [x] 插件化能够自由开关和调整优先级。
|
||||
- [x] 每个插件可在插件文件夹内维护独立的配置文件,方便代码的测试和调试,可以在独立的仓库开发插件。
|
||||
|
||||
## 插件安装方法
|
||||
|
||||
在本仓库中预置了一些插件,如果要安装其他仓库的插件,有两种方法。
|
||||
|
||||
- 第一种方法是在将下载的插件文件都解压到"plugins"文件夹的一个单独的文件夹,最终插件的代码都位于"plugins/PLUGIN_NAME/*"中。启动程序后,如果插件的目录结构正确,插件会自动被扫描加载。除此以外,注意你还需要安装文件夹中`requirements.txt`中的依赖。
|
||||
|
||||
- 第二种方法是`Godcmd`插件,它是预置的管理员插件,能够让程序在运行时就能安装插件,它能够自动安装依赖。
|
||||
|
||||
安装插件的命令是"#installp [仓库源](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/plugins/source.json)记录的插件名/仓库地址"。这是管理员命令,认证方法在[这里](https://github.com/zhayujie/chatgpt-on-wechat/tree/master/plugins/godcmd)。
|
||||
|
||||
- 安装[仓库源](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/plugins/source.json)记录的插件:#installp sdwebui
|
||||
|
||||
- 安装指定仓库的插件:#installp https://github.com/lanvent/plugin_sdwebui.git
|
||||
|
||||
在安装之后,需要执行"#scanp"命令来扫描加载新安装的插件(或者重新启动程序)。
|
||||
|
||||
安装插件后需要注意有些插件有自己的配置模板,一般要去掉".template"新建一个配置文件。
|
||||
|
||||
## 插件化实现
|
||||
|
||||
插件化实现是在收到消息到发送回复的各个步骤之间插入触发事件实现的。
|
||||
|
||||
### 消息处理过程
|
||||
|
||||
在了解插件触发事件前,首先需要了解程序收到消息到发送回复的整个过程。
|
||||
|
||||
插件化版本中,消息处理过程可以分为4个步骤:
|
||||
```
|
||||
1.收到消息 ---> 2.产生回复 ---> 3.包装回复 ---> 4.发送回复
|
||||
```
|
||||
|
||||
以下是它们的默认处理逻辑(太长不看,可跳到[插件编写示例](#插件编写示例)):
|
||||
|
||||
**注意以下包含的代码是`v1.1.0`中的片段,已过时,只可用于理解事件,最新的默认代码逻辑请参考[chat_channel](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/channel/chat_channel.py)**
|
||||
|
||||
#### 1. 收到消息
|
||||
|
||||
负责接收用户消息,根据用户的配置,判断本条消息是否触发机器人。如果触发,则会判断该消息的类型(声音、文本、画图命令等),将消息包装成如下的`Context`交付给下一个步骤。
|
||||
|
||||
```python
|
||||
class ContextType (Enum):
|
||||
TEXT = 1 # 文本消息
|
||||
VOICE = 2 # 音频消息
|
||||
IMAGE_CREATE = 3 # 创建图片命令
|
||||
class Context:
|
||||
def __init__(self, type : ContextType = None , content = None, kwargs = dict()):
|
||||
self.type = type
|
||||
self.content = content
|
||||
self.kwargs = kwargs
|
||||
def __getitem__(self, key):
|
||||
return self.kwargs[key]
|
||||
```
|
||||
|
||||
`Context`中除了存放消息类型和内容外,还存放了一些与会话相关的参数。
|
||||
|
||||
例如,当收到用户私聊消息时,会存放以下的会话参数。
|
||||
|
||||
```python
|
||||
context.kwargs = {'isgroup': False, 'msg': msg, 'receiver': other_user_id, 'session_id': other_user_id}
|
||||
```
|
||||
|
||||
- `isgroup`: `Context`是否是群聊消息。
|
||||
- `msg`: `itchat`中原始的消息对象。
|
||||
- `receiver`: 需要回复消息的对象ID。
|
||||
- `session_id`: 会话ID(一般是发送触发bot消息的用户ID,如果在群聊中并且`conf`里设置了`group_chat_in_one_session`,那么此处便是群聊ID)
|
||||
|
||||
#### 2. 产生回复
|
||||
|
||||
处理消息并产生回复。目前默认处理逻辑是根据`Context`的类型交付给对应的bot,并产生回复`Reply`。 如果本步骤没有产生任何回复,那么会跳过之后的所有步骤。
|
||||
|
||||
```python
|
||||
if context.type == ContextType.TEXT or context.type == ContextType.IMAGE_CREATE:
|
||||
reply = super().build_reply_content(context.content, context) #文字跟画图交付给chatgpt
|
||||
elif context.type == ContextType.VOICE: # 声音先进行语音转文字后,修改Context类型为文字后,再交付给chatgpt
|
||||
cmsg = context['msg']
|
||||
cmsg.prepare()
|
||||
file_name = context.content
|
||||
reply = super().build_voice_to_text(file_name)
|
||||
if reply.type != ReplyType.ERROR and reply.type != ReplyType.INFO:
|
||||
context.content = reply.content # 语音转文字后,将文字内容作为新的context
|
||||
context.type = ContextType.TEXT
|
||||
reply = super().build_reply_content(context.content, context)
|
||||
if reply.type == ReplyType.TEXT:
|
||||
if conf().get('voice_reply_voice'):
|
||||
reply = super().build_text_to_voice(reply.content)
|
||||
```
|
||||
|
||||
回复`Reply`的定义如下所示,它允许Bot可以回复多类不同的消息。同时也加入了`INFO`和`ERROR`消息类型区分系统提示和系统错误。
|
||||
|
||||
```python
|
||||
class ReplyType(Enum):
|
||||
TEXT = 1 # 文本
|
||||
VOICE = 2 # 音频文件
|
||||
IMAGE = 3 # 图片文件
|
||||
IMAGE_URL = 4 # 图片URL
|
||||
|
||||
INFO = 9
|
||||
ERROR = 10
|
||||
class Reply:
|
||||
def __init__(self, type : ReplyType = None , content = None):
|
||||
self.type = type
|
||||
self.content = content
|
||||
```
|
||||
|
||||
#### 3. 装饰回复
|
||||
|
||||
根据`Context`和回复`Reply`的类型,对回复的内容进行装饰。目前的装饰有以下两种:
|
||||
|
||||
- `TEXT`文本回复:如果这次消息需要的回复是`VOICE`,进行文字转语音回复之后再次装饰。 否则根据是否在群聊中来决定是艾特接收方还是添加回复的前缀。
|
||||
|
||||
- `INFO`或`ERROR`类型,会在消息前添加对应的系统提示字样。
|
||||
|
||||
如下是默认逻辑的代码:
|
||||
|
||||
```python
|
||||
if reply.type == ReplyType.TEXT:
|
||||
reply_text = reply.content
|
||||
if context.get('desire_rtype') == ReplyType.VOICE:
|
||||
reply = super().build_text_to_voice(reply.content)
|
||||
return self._decorate_reply(context, reply)
|
||||
if context['isgroup']:
|
||||
reply_text = '@' + context['msg'].actual_user_nickname + ' ' + reply_text.strip()
|
||||
reply_text = conf().get("group_chat_reply_prefix", "")+reply_text
|
||||
else:
|
||||
reply_text = conf().get("single_chat_reply_prefix", "")+reply_text
|
||||
reply.content = reply_text
|
||||
elif reply.type == ReplyType.ERROR or reply.type == ReplyType.INFO:
|
||||
reply.content = str(reply.type)+":\n" + reply.content
|
||||
```
|
||||
|
||||
#### 4. 发送回复
|
||||
|
||||
根据`Reply`的类型,默认逻辑调用不同的发送函数发送回复给接收方`context["receiver"]`。
|
||||
|
||||
### 插件触发事件
|
||||
|
||||
主程序目前会在各个消息步骤间触发事件,监听相应事件的插件会按照优先级,顺序调用事件处理函数。
|
||||
|
||||
目前支持三类触发事件:
|
||||
```
|
||||
1.收到消息
|
||||
---> `ON_HANDLE_CONTEXT`
|
||||
2.产生回复
|
||||
---> `ON_DECORATE_REPLY`
|
||||
3.装饰回复
|
||||
---> `ON_SEND_REPLY`
|
||||
4.发送回复
|
||||
```
|
||||
|
||||
触发事件会产生事件的上下文`EventContext`,它包含了以下信息:
|
||||
|
||||
`EventContext(Event事件类型, {'channel' : 消息channel, 'context': Context, 'reply': Reply})`
|
||||
|
||||
插件处理函数可通过修改`EventContext`中的`context`和`reply`来实现功能。
|
||||
|
||||
## 插件编写示例
|
||||
|
||||
以`plugins/hello`为例,其中编写了一个简单的`Hello`插件。
|
||||
|
||||
### 1. 创建插件
|
||||
|
||||
在`plugins`目录下创建一个插件文件夹`hello`。然后,在该文件夹中创建``__init__.py``文件,在``__init__.py``中将其他编写的模块文件导入。在程序启动时,插件管理器会读取``__init__.py``的所有内容。
|
||||
|
||||
```
|
||||
plugins/
|
||||
└── hello
|
||||
├── __init__.py
|
||||
└── hello.py
|
||||
```
|
||||
|
||||
``__init__.py``的内容:
|
||||
```
|
||||
from .hello import *
|
||||
```
|
||||
|
||||
### 2. 编写插件类
|
||||
|
||||
在`hello.py`文件中,创建插件类,它继承自`Plugin`。
|
||||
|
||||
在类定义之前需要使用`@plugins.register`装饰器注册插件,并填写插件的相关信息,其中`desire_priority`表示插件默认的优先级,越大优先级越高。初次加载插件后可在`plugins/plugins.json`中修改插件优先级。
|
||||
|
||||
并在`__init__`中绑定你编写的事件处理函数。
|
||||
|
||||
`Hello`插件为事件`ON_HANDLE_CONTEXT`绑定了一个处理函数`on_handle_context`,它表示之后每次生成回复前,都会由`on_handle_context`先处理。
|
||||
|
||||
PS: `ON_HANDLE_CONTEXT`是最常用的事件,如果要根据不同的消息来生成回复,就用它。
|
||||
|
||||
```python
|
||||
@plugins.register(name="Hello", desc="A simple plugin that says hello", version="0.1", author="lanvent", desire_priority= -1)
|
||||
class Hello(Plugin):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.handlers[Event.ON_HANDLE_CONTEXT] = self.on_handle_context
|
||||
logger.info("[Hello] inited")
|
||||
```
|
||||
|
||||
### 3. 编写事件处理函数
|
||||
|
||||
#### 修改事件上下文
|
||||
|
||||
事件处理函数接收一个`EventContext`对象`e_context`作为参数。`e_context`包含了事件相关信息,利用`e_context['key']`来访问这些信息。
|
||||
|
||||
`EventContext(Event事件类型, {'channel' : 消息channel, 'context': Context, 'reply': Reply})`
|
||||
|
||||
处理函数中通过修改`e_context`对象中的事件相关信息来实现所需功能,比如更改`e_context['reply']`中的内容可以修改回复。
|
||||
|
||||
#### 决定是否交付给下个插件或默认逻辑
|
||||
|
||||
在处理函数结束时,还需要设置`e_context`对象的`action`属性,它决定如何继续处理事件。目前有以下三种处理方式:
|
||||
|
||||
- `EventAction.CONTINUE`: 事件未结束,继续交给下个插件处理,如果没有下个插件,则交付给默认的事件处理逻辑。
|
||||
- `EventAction.BREAK`: 事件结束,不再给下个插件处理,交付给默认的处理逻辑。
|
||||
- `EventAction.BREAK_PASS`: 事件结束,不再给下个插件处理,跳过默认的处理逻辑。
|
||||
|
||||
#### 示例处理函数
|
||||
|
||||
`Hello`插件处理`Context`类型为`TEXT`的消息:
|
||||
|
||||
- 如果内容是`Hello`,就将回复设置为`Hello+用户昵称`,并跳过之后的插件和默认逻辑。
|
||||
- 如果内容是`End`,就将`Context`的类型更改为`IMAGE_CREATE`,并让事件继续,如果最终交付到默认逻辑,会调用默认的画图Bot来画画。
|
||||
|
||||
```python
|
||||
def on_handle_context(self, e_context: EventContext):
|
||||
if e_context['context'].type != ContextType.TEXT:
|
||||
return
|
||||
content = e_context['context'].content
|
||||
if content == "Hello":
|
||||
reply = Reply()
|
||||
reply.type = ReplyType.TEXT
|
||||
msg:ChatMessage = e_context['context']['msg']
|
||||
if e_context['context']['isgroup']:
|
||||
reply.content = f"Hello, {msg.actual_user_nickname} from {msg.from_user_nickname}"
|
||||
else:
|
||||
reply.content = f"Hello, {msg.from_user_nickname}"
|
||||
e_context['reply'] = reply
|
||||
e_context.action = EventAction.BREAK_PASS # 事件结束,并跳过处理context的默认逻辑
|
||||
if content == "End":
|
||||
# 如果是文本消息"End",将请求转换成"IMAGE_CREATE",并将content设置为"The World"
|
||||
e_context['context'].type = ContextType.IMAGE_CREATE
|
||||
content = "The World"
|
||||
e_context.action = EventAction.CONTINUE # 事件继续,交付给下个插件或默认逻辑
|
||||
```
|
||||
|
||||
## 插件设计建议
|
||||
|
||||
- 尽情将你想要的个性化功能设计为插件。
|
||||
- 一个插件目录建议只注册一个插件类。建议使用单独的仓库维护插件,便于更新。
|
||||
|
||||
在测试调试好后提交`PR`,把自己的仓库加入到[仓库源](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/plugins/source.json)中。
|
||||
|
||||
- 插件的config文件、使用说明`README.md`、`requirement.txt`等放置在插件目录中。
|
||||
- 默认优先级不要超过管理员插件`Godcmd`的优先级(999),`Godcmd`插件提供了配置管理、插件管理等功能。
|
||||
9
plugins/__init__.py
Normal file
9
plugins/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from .event import *
|
||||
from .plugin import *
|
||||
from .plugin_manager import PluginManager
|
||||
|
||||
instance = PluginManager()
|
||||
|
||||
register = instance.register
|
||||
# load_plugins = instance.load_plugins
|
||||
# emit_event = instance.emit_event
|
||||
1
plugins/banwords/.gitignore
vendored
Normal file
1
plugins/banwords/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
banwords.txt
|
||||
27
plugins/banwords/README.md
Normal file
27
plugins/banwords/README.md
Normal file
@@ -0,0 +1,27 @@
|
||||
|
||||
## 插件描述
|
||||
|
||||
简易的敏感词插件,暂不支持分词,请自行导入词库到插件文件夹中的`banwords.txt`,每行一个词,一个参考词库是[1](https://github.com/cjh0613/tencent-sensitive-words/blob/main/sensitive_words_lines.txt)。
|
||||
|
||||
使用前将`config.json.template`复制为`config.json`,并自行配置。
|
||||
|
||||
目前插件对消息的默认处理行为有如下两种:
|
||||
|
||||
- `ignore` : 无视这条消息。
|
||||
- `replace` : 将消息中的敏感词替换成"*",并回复违规。
|
||||
|
||||
```json
|
||||
"action": "replace",
|
||||
"reply_filter": true,
|
||||
"reply_action": "ignore"
|
||||
```
|
||||
|
||||
在以上配置项中:
|
||||
|
||||
- `action`: 对用户消息的默认处理行为
|
||||
- `reply_filter`: 是否对ChatGPT的回复也进行敏感词过滤
|
||||
- `reply_action`: 如果开启了回复过滤,对回复的默认处理行为
|
||||
|
||||
## 致谢
|
||||
|
||||
搜索功能实现来自https://github.com/toolgood/ToolGood.Words
|
||||
1
plugins/banwords/__init__.py
Normal file
1
plugins/banwords/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .banwords import *
|
||||
99
plugins/banwords/banwords.py
Normal file
99
plugins/banwords/banwords.py
Normal file
@@ -0,0 +1,99 @@
|
||||
# encoding:utf-8
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
import plugins
|
||||
from bridge.context import ContextType
|
||||
from bridge.reply import Reply, ReplyType
|
||||
from common.log import logger
|
||||
from plugins import *
|
||||
|
||||
from .lib.WordsSearch import WordsSearch
|
||||
|
||||
|
||||
@plugins.register(
|
||||
name="Banwords",
|
||||
desire_priority=100,
|
||||
hidden=True,
|
||||
desc="判断消息中是否有敏感词、决定是否回复。",
|
||||
version="1.0",
|
||||
author="lanvent",
|
||||
)
|
||||
class Banwords(Plugin):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
try:
|
||||
curdir = os.path.dirname(__file__)
|
||||
config_path = os.path.join(curdir, "config.json")
|
||||
conf = None
|
||||
if not os.path.exists(config_path):
|
||||
conf = {"action": "ignore"}
|
||||
with open(config_path, "w") as f:
|
||||
json.dump(conf, f, indent=4)
|
||||
else:
|
||||
with open(config_path, "r") as f:
|
||||
conf = json.load(f)
|
||||
self.searchr = WordsSearch()
|
||||
self.action = conf["action"]
|
||||
banwords_path = os.path.join(curdir, "banwords.txt")
|
||||
with open(banwords_path, "r", encoding="utf-8") as f:
|
||||
words = []
|
||||
for line in f:
|
||||
word = line.strip()
|
||||
if word:
|
||||
words.append(word)
|
||||
self.searchr.SetKeywords(words)
|
||||
self.handlers[Event.ON_HANDLE_CONTEXT] = self.on_handle_context
|
||||
if conf.get("reply_filter", True):
|
||||
self.handlers[Event.ON_DECORATE_REPLY] = self.on_decorate_reply
|
||||
self.reply_action = conf.get("reply_action", "ignore")
|
||||
logger.info("[Banwords] inited")
|
||||
except Exception as e:
|
||||
logger.warn("[Banwords] init failed, ignore or see https://github.com/zhayujie/chatgpt-on-wechat/tree/master/plugins/banwords .")
|
||||
raise e
|
||||
|
||||
def on_handle_context(self, e_context: EventContext):
|
||||
if e_context["context"].type not in [
|
||||
ContextType.TEXT,
|
||||
ContextType.IMAGE_CREATE,
|
||||
]:
|
||||
return
|
||||
|
||||
content = e_context["context"].content
|
||||
logger.debug("[Banwords] on_handle_context. content: %s" % content)
|
||||
if self.action == "ignore":
|
||||
f = self.searchr.FindFirst(content)
|
||||
if f:
|
||||
logger.info("[Banwords] %s in message" % f["Keyword"])
|
||||
e_context.action = EventAction.BREAK_PASS
|
||||
return
|
||||
elif self.action == "replace":
|
||||
if self.searchr.ContainsAny(content):
|
||||
reply = Reply(ReplyType.INFO, "发言中包含敏感词,请重试: \n" + self.searchr.Replace(content))
|
||||
e_context["reply"] = reply
|
||||
e_context.action = EventAction.BREAK_PASS
|
||||
return
|
||||
|
||||
def on_decorate_reply(self, e_context: EventContext):
|
||||
if e_context["reply"].type not in [ReplyType.TEXT]:
|
||||
return
|
||||
|
||||
reply = e_context["reply"]
|
||||
content = reply.content
|
||||
if self.reply_action == "ignore":
|
||||
f = self.searchr.FindFirst(content)
|
||||
if f:
|
||||
logger.info("[Banwords] %s in reply" % f["Keyword"])
|
||||
e_context["reply"] = None
|
||||
e_context.action = EventAction.BREAK_PASS
|
||||
return
|
||||
elif self.reply_action == "replace":
|
||||
if self.searchr.ContainsAny(content):
|
||||
reply = Reply(ReplyType.INFO, "已替换回复中的敏感词: \n" + self.searchr.Replace(content))
|
||||
e_context["reply"] = reply
|
||||
e_context.action = EventAction.CONTINUE
|
||||
return
|
||||
|
||||
def get_help_text(self, **kwargs):
|
||||
return "过滤消息中的敏感词。"
|
||||
3
plugins/banwords/banwords.txt.template
Normal file
3
plugins/banwords/banwords.txt.template
Normal file
@@ -0,0 +1,3 @@
|
||||
nipples
|
||||
pennis
|
||||
法轮功
|
||||
5
plugins/banwords/config.json.template
Normal file
5
plugins/banwords/config.json.template
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"action": "replace",
|
||||
"reply_filter": true,
|
||||
"reply_action": "ignore"
|
||||
}
|
||||
250
plugins/banwords/lib/WordsSearch.py
Normal file
250
plugins/banwords/lib/WordsSearch.py
Normal file
@@ -0,0 +1,250 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding:utf-8 -*-
|
||||
# ToolGood.Words.WordsSearch.py
|
||||
# 2020, Lin Zhijun, https://github.com/toolgood/ToolGood.Words
|
||||
# Licensed under the Apache License 2.0
|
||||
# 更新日志
|
||||
# 2020.04.06 第一次提交
|
||||
# 2020.05.16 修改,支持大于0xffff的字符
|
||||
|
||||
__all__ = ['WordsSearch']
|
||||
__author__ = 'Lin Zhijun'
|
||||
__date__ = '2020.05.16'
|
||||
|
||||
class TrieNode():
|
||||
def __init__(self):
|
||||
self.Index = 0
|
||||
self.Index = 0
|
||||
self.Layer = 0
|
||||
self.End = False
|
||||
self.Char = ''
|
||||
self.Results = []
|
||||
self.m_values = {}
|
||||
self.Failure = None
|
||||
self.Parent = None
|
||||
|
||||
def Add(self,c):
|
||||
if c in self.m_values :
|
||||
return self.m_values[c]
|
||||
node = TrieNode()
|
||||
node.Parent = self
|
||||
node.Char = c
|
||||
self.m_values[c] = node
|
||||
return node
|
||||
|
||||
def SetResults(self,index):
|
||||
if (self.End == False):
|
||||
self.End = True
|
||||
self.Results.append(index)
|
||||
|
||||
class TrieNode2():
|
||||
def __init__(self):
|
||||
self.End = False
|
||||
self.Results = []
|
||||
self.m_values = {}
|
||||
self.minflag = 0xffff
|
||||
self.maxflag = 0
|
||||
|
||||
def Add(self,c,node3):
|
||||
if (self.minflag > c):
|
||||
self.minflag = c
|
||||
if (self.maxflag < c):
|
||||
self.maxflag = c
|
||||
self.m_values[c] = node3
|
||||
|
||||
def SetResults(self,index):
|
||||
if (self.End == False) :
|
||||
self.End = True
|
||||
if (index in self.Results )==False :
|
||||
self.Results.append(index)
|
||||
|
||||
def HasKey(self,c):
|
||||
return c in self.m_values
|
||||
|
||||
|
||||
def TryGetValue(self,c):
|
||||
if (self.minflag <= c and self.maxflag >= c):
|
||||
if c in self.m_values:
|
||||
return self.m_values[c]
|
||||
return None
|
||||
|
||||
|
||||
class WordsSearch():
|
||||
def __init__(self):
|
||||
self._first = {}
|
||||
self._keywords = []
|
||||
self._indexs=[]
|
||||
|
||||
def SetKeywords(self,keywords):
|
||||
self._keywords = keywords
|
||||
self._indexs=[]
|
||||
for i in range(len(keywords)):
|
||||
self._indexs.append(i)
|
||||
|
||||
root = TrieNode()
|
||||
allNodeLayer={}
|
||||
|
||||
for i in range(len(self._keywords)): # for (i = 0; i < _keywords.length; i++)
|
||||
p = self._keywords[i]
|
||||
nd = root
|
||||
for j in range(len(p)): # for (j = 0; j < p.length; j++)
|
||||
nd = nd.Add(ord(p[j]))
|
||||
if (nd.Layer == 0):
|
||||
nd.Layer = j + 1
|
||||
if nd.Layer in allNodeLayer:
|
||||
allNodeLayer[nd.Layer].append(nd)
|
||||
else:
|
||||
allNodeLayer[nd.Layer]=[]
|
||||
allNodeLayer[nd.Layer].append(nd)
|
||||
nd.SetResults(i)
|
||||
|
||||
|
||||
allNode = []
|
||||
allNode.append(root)
|
||||
for key in allNodeLayer.keys():
|
||||
for nd in allNodeLayer[key]:
|
||||
allNode.append(nd)
|
||||
allNodeLayer=None
|
||||
|
||||
for i in range(len(allNode)): # for (i = 0; i < allNode.length; i++)
|
||||
if i==0 :
|
||||
continue
|
||||
nd=allNode[i]
|
||||
nd.Index = i
|
||||
r = nd.Parent.Failure
|
||||
c = nd.Char
|
||||
while (r != None and (c in r.m_values)==False):
|
||||
r = r.Failure
|
||||
if (r == None):
|
||||
nd.Failure = root
|
||||
else:
|
||||
nd.Failure = r.m_values[c]
|
||||
for key2 in nd.Failure.Results :
|
||||
nd.SetResults(key2)
|
||||
root.Failure = root
|
||||
|
||||
allNode2 = []
|
||||
for i in range(len(allNode)): # for (i = 0; i < allNode.length; i++)
|
||||
allNode2.append( TrieNode2())
|
||||
|
||||
for i in range(len(allNode2)): # for (i = 0; i < allNode2.length; i++)
|
||||
oldNode = allNode[i]
|
||||
newNode = allNode2[i]
|
||||
|
||||
for key in oldNode.m_values :
|
||||
index = oldNode.m_values[key].Index
|
||||
newNode.Add(key, allNode2[index])
|
||||
|
||||
for index in range(len(oldNode.Results)): # for (index = 0; index < oldNode.Results.length; index++)
|
||||
item = oldNode.Results[index]
|
||||
newNode.SetResults(item)
|
||||
|
||||
oldNode=oldNode.Failure
|
||||
while oldNode != root:
|
||||
for key in oldNode.m_values :
|
||||
if (newNode.HasKey(key) == False):
|
||||
index = oldNode.m_values[key].Index
|
||||
newNode.Add(key, allNode2[index])
|
||||
for index in range(len(oldNode.Results)):
|
||||
item = oldNode.Results[index]
|
||||
newNode.SetResults(item)
|
||||
oldNode=oldNode.Failure
|
||||
allNode = None
|
||||
root = None
|
||||
|
||||
# first = []
|
||||
# for index in range(65535):# for (index = 0; index < 0xffff; index++)
|
||||
# first.append(None)
|
||||
|
||||
# for key in allNode2[0].m_values :
|
||||
# first[key] = allNode2[0].m_values[key]
|
||||
|
||||
self._first = allNode2[0]
|
||||
|
||||
|
||||
def FindFirst(self,text):
|
||||
ptr = None
|
||||
for index in range(len(text)): # for (index = 0; index < text.length; index++)
|
||||
t =ord(text[index]) # text.charCodeAt(index)
|
||||
tn = None
|
||||
if (ptr == None):
|
||||
tn = self._first.TryGetValue(t)
|
||||
else:
|
||||
tn = ptr.TryGetValue(t)
|
||||
if (tn==None):
|
||||
tn = self._first.TryGetValue(t)
|
||||
|
||||
|
||||
if (tn != None):
|
||||
if (tn.End):
|
||||
item = tn.Results[0]
|
||||
keyword = self._keywords[item]
|
||||
return { "Keyword": keyword, "Success": True, "End": index, "Start": index + 1 - len(keyword), "Index": self._indexs[item] }
|
||||
ptr = tn
|
||||
return None
|
||||
|
||||
def FindAll(self,text):
|
||||
ptr = None
|
||||
list = []
|
||||
|
||||
for index in range(len(text)): # for (index = 0; index < text.length; index++)
|
||||
t =ord(text[index]) # text.charCodeAt(index)
|
||||
tn = None
|
||||
if (ptr == None):
|
||||
tn = self._first.TryGetValue(t)
|
||||
else:
|
||||
tn = ptr.TryGetValue(t)
|
||||
if (tn==None):
|
||||
tn = self._first.TryGetValue(t)
|
||||
|
||||
|
||||
if (tn != None):
|
||||
if (tn.End):
|
||||
for j in range(len(tn.Results)): # for (j = 0; j < tn.Results.length; j++)
|
||||
item = tn.Results[j]
|
||||
keyword = self._keywords[item]
|
||||
list.append({ "Keyword": keyword, "Success": True, "End": index, "Start": index + 1 - len(keyword), "Index": self._indexs[item] })
|
||||
ptr = tn
|
||||
return list
|
||||
|
||||
|
||||
def ContainsAny(self,text):
|
||||
ptr = None
|
||||
for index in range(len(text)): # for (index = 0; index < text.length; index++)
|
||||
t =ord(text[index]) # text.charCodeAt(index)
|
||||
tn = None
|
||||
if (ptr == None):
|
||||
tn = self._first.TryGetValue(t)
|
||||
else:
|
||||
tn = ptr.TryGetValue(t)
|
||||
if (tn==None):
|
||||
tn = self._first.TryGetValue(t)
|
||||
|
||||
if (tn != None):
|
||||
if (tn.End):
|
||||
return True
|
||||
ptr = tn
|
||||
return False
|
||||
|
||||
def Replace(self,text, replaceChar = '*'):
|
||||
result = list(text)
|
||||
|
||||
ptr = None
|
||||
for i in range(len(text)): # for (i = 0; i < text.length; i++)
|
||||
t =ord(text[i]) # text.charCodeAt(index)
|
||||
tn = None
|
||||
if (ptr == None):
|
||||
tn = self._first.TryGetValue(t)
|
||||
else:
|
||||
tn = ptr.TryGetValue(t)
|
||||
if (tn==None):
|
||||
tn = self._first.TryGetValue(t)
|
||||
|
||||
if (tn != None):
|
||||
if (tn.End):
|
||||
maxLength = len( self._keywords[tn.Results[0]])
|
||||
start = i + 1 - maxLength
|
||||
for j in range(start,i+1): # for (j = start; j <= i; j++)
|
||||
result[j] = replaceChar
|
||||
ptr = tn
|
||||
return ''.join(result)
|
||||
30
plugins/bdunit/README.md
Normal file
30
plugins/bdunit/README.md
Normal file
@@ -0,0 +1,30 @@
|
||||
## 插件说明
|
||||
|
||||
利用百度UNIT实现智能对话
|
||||
|
||||
- 1.解决问题:chatgpt无法处理的指令,交给百度UNIT处理如:天气,日期时间,数学运算等
|
||||
- 2.如问时间:现在几点钟,今天几号
|
||||
- 3.如问天气:明天广州天气怎么样,这个周末深圳会不会下雨
|
||||
- 4.如问数学运算:23+45=多少,100-23=多少,35转化为二进制是多少?
|
||||
|
||||
## 使用说明
|
||||
|
||||
### 获取apikey
|
||||
|
||||
在百度UNIT官网上自己创建应用,申请百度机器人,可以把预先训练好的模型导入到自己的应用中,
|
||||
|
||||
see https://ai.baidu.com/unit/home#/home?track=61fe1b0d3407ce3face1d92cb5c291087095fc10c8377aaf https://console.bce.baidu.com/ai平台申请
|
||||
|
||||
### 配置文件
|
||||
|
||||
将文件夹中`config.json.template`复制为`config.json`。
|
||||
|
||||
在其中填写百度UNIT官网上获取应用的API Key和Secret Key
|
||||
|
||||
``` json
|
||||
{
|
||||
"service_id": "s...", #"机器人ID"
|
||||
"api_key": "",
|
||||
"secret_key": ""
|
||||
}
|
||||
```
|
||||
1
plugins/bdunit/__init__.py
Normal file
1
plugins/bdunit/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .bdunit import *
|
||||
257
plugins/bdunit/bdunit.py
Normal file
257
plugins/bdunit/bdunit.py
Normal file
@@ -0,0 +1,257 @@
|
||||
# encoding:utf-8
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from uuid import getnode as get_mac
|
||||
|
||||
import requests
|
||||
|
||||
import plugins
|
||||
from bridge.context import ContextType
|
||||
from bridge.reply import Reply, ReplyType
|
||||
from common.log import logger
|
||||
from plugins import *
|
||||
|
||||
"""利用百度UNIT实现智能对话
|
||||
如果命中意图,返回意图对应的回复,否则返回继续交付给下个插件处理
|
||||
"""
|
||||
|
||||
|
||||
@plugins.register(
|
||||
name="BDunit",
|
||||
desire_priority=0,
|
||||
hidden=True,
|
||||
desc="Baidu unit bot system",
|
||||
version="0.1",
|
||||
author="jackson",
|
||||
)
|
||||
class BDunit(Plugin):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
try:
|
||||
curdir = os.path.dirname(__file__)
|
||||
config_path = os.path.join(curdir, "config.json")
|
||||
conf = None
|
||||
if not os.path.exists(config_path):
|
||||
raise Exception("config.json not found")
|
||||
else:
|
||||
with open(config_path, "r") as f:
|
||||
conf = json.load(f)
|
||||
self.service_id = conf["service_id"]
|
||||
self.api_key = conf["api_key"]
|
||||
self.secret_key = conf["secret_key"]
|
||||
self.access_token = self.get_token()
|
||||
self.handlers[Event.ON_HANDLE_CONTEXT] = self.on_handle_context
|
||||
logger.info("[BDunit] inited")
|
||||
except Exception as e:
|
||||
logger.warn("[BDunit] init failed, ignore ")
|
||||
raise e
|
||||
|
||||
def on_handle_context(self, e_context: EventContext):
|
||||
if e_context["context"].type != ContextType.TEXT:
|
||||
return
|
||||
|
||||
content = e_context["context"].content
|
||||
logger.debug("[BDunit] on_handle_context. content: %s" % content)
|
||||
parsed = self.getUnit2(content)
|
||||
intent = self.getIntent(parsed)
|
||||
if intent: # 找到意图
|
||||
logger.debug("[BDunit] Baidu_AI Intent= %s", intent)
|
||||
reply = Reply()
|
||||
reply.type = ReplyType.TEXT
|
||||
reply.content = self.getSay(parsed)
|
||||
e_context["reply"] = reply
|
||||
e_context.action = EventAction.BREAK_PASS # 事件结束,并跳过处理context的默认逻辑
|
||||
else:
|
||||
e_context.action = EventAction.CONTINUE # 事件继续,交付给下个插件或默认逻辑
|
||||
|
||||
def get_help_text(self, **kwargs):
|
||||
help_text = "本插件会处理询问实时日期时间,天气,数学运算等问题,这些技能由您的百度智能对话UNIT决定\n"
|
||||
return help_text
|
||||
|
||||
def get_token(self):
|
||||
"""获取访问百度UUNIT 的access_token
|
||||
#param api_key: UNIT apk_key
|
||||
#param secret_key: UNIT secret_key
|
||||
Returns:
|
||||
string: access_token
|
||||
"""
|
||||
url = "https://aip.baidubce.com/oauth/2.0/token?client_id={}&client_secret={}&grant_type=client_credentials".format(self.api_key, self.secret_key)
|
||||
payload = ""
|
||||
headers = {"Content-Type": "application/json", "Accept": "application/json"}
|
||||
|
||||
response = requests.request("POST", url, headers=headers, data=payload)
|
||||
|
||||
# print(response.text)
|
||||
return response.json()["access_token"]
|
||||
|
||||
def getUnit(self, query):
|
||||
"""
|
||||
NLU 解析version 3.0
|
||||
:param query: 用户的指令字符串
|
||||
:returns: UNIT 解析结果。如果解析失败,返回 None
|
||||
"""
|
||||
|
||||
url = "https://aip.baidubce.com/rpc/2.0/unit/service/v3/chat?access_token=" + self.access_token
|
||||
request = {
|
||||
"query": query,
|
||||
"user_id": str(get_mac())[:32],
|
||||
"terminal_id": "88888",
|
||||
}
|
||||
body = {
|
||||
"log_id": str(uuid.uuid1()),
|
||||
"version": "3.0",
|
||||
"service_id": self.service_id,
|
||||
"session_id": str(uuid.uuid1()),
|
||||
"request": request,
|
||||
}
|
||||
try:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
response = requests.post(url, json=body, headers=headers)
|
||||
return json.loads(response.text)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def getUnit2(self, query):
|
||||
"""
|
||||
NLU 解析 version 2.0
|
||||
|
||||
:param query: 用户的指令字符串
|
||||
:returns: UNIT 解析结果。如果解析失败,返回 None
|
||||
"""
|
||||
url = "https://aip.baidubce.com/rpc/2.0/unit/service/chat?access_token=" + self.access_token
|
||||
request = {"query": query, "user_id": str(get_mac())[:32]}
|
||||
body = {
|
||||
"log_id": str(uuid.uuid1()),
|
||||
"version": "2.0",
|
||||
"service_id": self.service_id,
|
||||
"session_id": str(uuid.uuid1()),
|
||||
"request": request,
|
||||
}
|
||||
try:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
response = requests.post(url, json=body, headers=headers)
|
||||
return json.loads(response.text)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def getIntent(self, parsed):
|
||||
"""
|
||||
提取意图
|
||||
|
||||
:param parsed: UNIT 解析结果
|
||||
:returns: 意图数组
|
||||
"""
|
||||
if parsed and "result" in parsed and "response_list" in parsed["result"]:
|
||||
try:
|
||||
return parsed["result"]["response_list"][0]["schema"]["intent"]
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
return ""
|
||||
else:
|
||||
return ""
|
||||
|
||||
def hasIntent(self, parsed, intent):
|
||||
"""
|
||||
判断是否包含某个意图
|
||||
|
||||
:param parsed: UNIT 解析结果
|
||||
:param intent: 意图的名称
|
||||
:returns: True: 包含; False: 不包含
|
||||
"""
|
||||
if parsed and "result" in parsed and "response_list" in parsed["result"]:
|
||||
response_list = parsed["result"]["response_list"]
|
||||
for response in response_list:
|
||||
if "schema" in response and "intent" in response["schema"] and response["schema"]["intent"] == intent:
|
||||
return True
|
||||
return False
|
||||
else:
|
||||
return False
|
||||
|
||||
def getSlots(self, parsed, intent=""):
|
||||
"""
|
||||
提取某个意图的所有词槽
|
||||
|
||||
:param parsed: UNIT 解析结果
|
||||
:param intent: 意图的名称
|
||||
:returns: 词槽列表。你可以通过 name 属性筛选词槽,
|
||||
再通过 normalized_word 属性取出相应的值
|
||||
"""
|
||||
if parsed and "result" in parsed and "response_list" in parsed["result"]:
|
||||
response_list = parsed["result"]["response_list"]
|
||||
if intent == "":
|
||||
try:
|
||||
return parsed["result"]["response_list"][0]["schema"]["slots"]
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
return []
|
||||
for response in response_list:
|
||||
if "schema" in response and "intent" in response["schema"] and "slots" in response["schema"] and response["schema"]["intent"] == intent:
|
||||
return response["schema"]["slots"]
|
||||
return []
|
||||
else:
|
||||
return []
|
||||
|
||||
def getSlotWords(self, parsed, intent, name):
|
||||
"""
|
||||
找出命中某个词槽的内容
|
||||
|
||||
:param parsed: UNIT 解析结果
|
||||
:param intent: 意图的名称
|
||||
:param name: 词槽名
|
||||
:returns: 命中该词槽的值的列表。
|
||||
"""
|
||||
slots = self.getSlots(parsed, intent)
|
||||
words = []
|
||||
for slot in slots:
|
||||
if slot["name"] == name:
|
||||
words.append(slot["normalized_word"])
|
||||
return words
|
||||
|
||||
def getSayByConfidence(self, parsed):
|
||||
"""
|
||||
提取 UNIT 置信度最高的回复文本
|
||||
|
||||
:param parsed: UNIT 解析结果
|
||||
:returns: UNIT 的回复文本
|
||||
"""
|
||||
if parsed and "result" in parsed and "response_list" in parsed["result"]:
|
||||
response_list = parsed["result"]["response_list"]
|
||||
answer = {}
|
||||
for response in response_list:
|
||||
if (
|
||||
"schema" in response
|
||||
and "intent_confidence" in response["schema"]
|
||||
and (not answer or response["schema"]["intent_confidence"] > answer["schema"]["intent_confidence"])
|
||||
):
|
||||
answer = response
|
||||
return answer["action_list"][0]["say"]
|
||||
else:
|
||||
return ""
|
||||
|
||||
def getSay(self, parsed, intent=""):
|
||||
"""
|
||||
提取 UNIT 的回复文本
|
||||
|
||||
:param parsed: UNIT 解析结果
|
||||
:param intent: 意图的名称
|
||||
:returns: UNIT 的回复文本
|
||||
"""
|
||||
if parsed and "result" in parsed and "response_list" in parsed["result"]:
|
||||
response_list = parsed["result"]["response_list"]
|
||||
if intent == "":
|
||||
try:
|
||||
return response_list[0]["action_list"][0]["say"]
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
return ""
|
||||
for response in response_list:
|
||||
if "schema" in response and "intent" in response["schema"] and response["schema"]["intent"] == intent:
|
||||
try:
|
||||
return response["action_list"][0]["say"]
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
return ""
|
||||
return ""
|
||||
else:
|
||||
return ""
|
||||
5
plugins/bdunit/config.json.template
Normal file
5
plugins/bdunit/config.json.template
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"service_id": "s...",
|
||||
"api_key": "",
|
||||
"secret_key": ""
|
||||
}
|
||||
4
plugins/dungeon/README.md
Normal file
4
plugins/dungeon/README.md
Normal file
@@ -0,0 +1,4 @@
|
||||
玩地牢游戏的聊天插件,触发方法如下:
|
||||
|
||||
- `$开始冒险 <背景故事>` - 以<背景故事>开始一个地牢游戏,不填写会使用默认背景故事。之后聊天中你的所有消息会帮助ai完善这个故事。
|
||||
- `$停止冒险` - 停止一个地牢游戏,回归正常的ai。
|
||||
1
plugins/dungeon/__init__.py
Normal file
1
plugins/dungeon/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .dungeon import *
|
||||
106
plugins/dungeon/dungeon.py
Normal file
106
plugins/dungeon/dungeon.py
Normal file
@@ -0,0 +1,106 @@
|
||||
# encoding:utf-8
|
||||
|
||||
import plugins
|
||||
from bridge.bridge import Bridge
|
||||
from bridge.context import ContextType
|
||||
from bridge.reply import Reply, ReplyType
|
||||
from common import const
|
||||
from common.expired_dict import ExpiredDict
|
||||
from common.log import logger
|
||||
from config import conf
|
||||
from plugins import *
|
||||
|
||||
|
||||
# https://github.com/bupticybee/ChineseAiDungeonChatGPT
|
||||
class StoryTeller:
|
||||
def __init__(self, bot, sessionid, story):
|
||||
self.bot = bot
|
||||
self.sessionid = sessionid
|
||||
bot.sessions.clear_session(sessionid)
|
||||
self.first_interact = True
|
||||
self.story = story
|
||||
|
||||
def reset(self):
|
||||
self.bot.sessions.clear_session(self.sessionid)
|
||||
self.first_interact = True
|
||||
|
||||
def action(self, user_action):
|
||||
if user_action[-1] != "。":
|
||||
user_action = user_action + "。"
|
||||
if self.first_interact:
|
||||
prompt = (
|
||||
"""现在来充当一个文字冒险游戏,描述时候注意节奏,不要太快,仔细描述各个人物的心情和周边环境。一次只需写四到六句话。
|
||||
开头是,"""
|
||||
+ self.story
|
||||
+ " "
|
||||
+ user_action
|
||||
)
|
||||
self.first_interact = False
|
||||
else:
|
||||
prompt = """继续,一次只需要续写四到六句话,总共就只讲5分钟内发生的事情。""" + user_action
|
||||
return prompt
|
||||
|
||||
|
||||
@plugins.register(
|
||||
name="Dungeon",
|
||||
desire_priority=0,
|
||||
namecn="文字冒险",
|
||||
desc="A plugin to play dungeon game",
|
||||
version="1.0",
|
||||
author="lanvent",
|
||||
)
|
||||
class Dungeon(Plugin):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.handlers[Event.ON_HANDLE_CONTEXT] = self.on_handle_context
|
||||
logger.info("[Dungeon] inited")
|
||||
# 目前没有设计session过期事件,这里先暂时使用过期字典
|
||||
if conf().get("expires_in_seconds"):
|
||||
self.games = ExpiredDict(conf().get("expires_in_seconds"))
|
||||
else:
|
||||
self.games = dict()
|
||||
|
||||
def on_handle_context(self, e_context: EventContext):
|
||||
if e_context["context"].type != ContextType.TEXT:
|
||||
return
|
||||
bottype = Bridge().get_bot_type("chat")
|
||||
if bottype not in (const.CHATGPT, const.OPEN_AI):
|
||||
return
|
||||
bot = Bridge().get_bot("chat")
|
||||
content = e_context["context"].content[:]
|
||||
clist = e_context["context"].content.split(maxsplit=1)
|
||||
sessionid = e_context["context"]["session_id"]
|
||||
logger.debug("[Dungeon] on_handle_context. content: %s" % clist)
|
||||
trigger_prefix = conf().get("plugin_trigger_prefix", "$")
|
||||
if clist[0] == f"{trigger_prefix}停止冒险":
|
||||
if sessionid in self.games:
|
||||
self.games[sessionid].reset()
|
||||
del self.games[sessionid]
|
||||
reply = Reply(ReplyType.INFO, "冒险结束!")
|
||||
e_context["reply"] = reply
|
||||
e_context.action = EventAction.BREAK_PASS
|
||||
elif clist[0] == f"{trigger_prefix}开始冒险" or sessionid in self.games:
|
||||
if sessionid not in self.games or clist[0] == f"{trigger_prefix}开始冒险":
|
||||
if len(clist) > 1:
|
||||
story = clist[1]
|
||||
else:
|
||||
story = "你在树林里冒险,指不定会从哪里蹦出来一些奇怪的东西,你握紧手上的手枪,希望这次冒险能够找到一些值钱的东西,你往树林深处走去。"
|
||||
self.games[sessionid] = StoryTeller(bot, sessionid, story)
|
||||
reply = Reply(ReplyType.INFO, "冒险开始,你可以输入任意内容,让故事继续下去。故事背景是:" + story)
|
||||
e_context["reply"] = reply
|
||||
e_context.action = EventAction.BREAK_PASS # 事件结束,并跳过处理context的默认逻辑
|
||||
else:
|
||||
prompt = self.games[sessionid].action(content)
|
||||
e_context["context"].type = ContextType.TEXT
|
||||
e_context["context"].content = prompt
|
||||
e_context.action = EventAction.BREAK # 事件结束,不跳过处理context的默认逻辑
|
||||
|
||||
def get_help_text(self, **kwargs):
|
||||
help_text = "可以和机器人一起玩文字冒险游戏。\n"
|
||||
if kwargs.get("verbose") != True:
|
||||
return help_text
|
||||
trigger_prefix = conf().get("plugin_trigger_prefix", "$")
|
||||
help_text = f"{trigger_prefix}开始冒险 " + "背景故事: 开始一个基于{背景故事}的文字冒险,之后你的所有消息会协助完善这个故事。\n" + f"{trigger_prefix}停止冒险: 结束游戏。\n"
|
||||
if kwargs.get("verbose") == True:
|
||||
help_text += f"\n命令例子: '{trigger_prefix}开始冒险 你在树林里冒险,指不定会从哪里蹦出来一些奇怪的东西,你握紧手上的手枪,希望这次冒险能够找到一些值钱的东西,你往树林深处走去。'"
|
||||
return help_text
|
||||
52
plugins/event.py
Normal file
52
plugins/event.py
Normal file
@@ -0,0 +1,52 @@
|
||||
# encoding:utf-8
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class Event(Enum):
|
||||
ON_RECEIVE_MESSAGE = 1 # 收到消息
|
||||
"""
|
||||
e_context = { "channel": 消息channel, "context" : 本次消息的context}
|
||||
"""
|
||||
|
||||
ON_HANDLE_CONTEXT = 2 # 处理消息前
|
||||
"""
|
||||
e_context = { "channel": 消息channel, "context" : 本次消息的context, "reply" : 目前的回复,初始为空 }
|
||||
"""
|
||||
|
||||
ON_DECORATE_REPLY = 3 # 得到回复后准备装饰
|
||||
"""
|
||||
e_context = { "channel": 消息channel, "context" : 本次消息的context, "reply" : 目前的回复 }
|
||||
"""
|
||||
|
||||
ON_SEND_REPLY = 4 # 发送回复前
|
||||
"""
|
||||
e_context = { "channel": 消息channel, "context" : 本次消息的context, "reply" : 目前的回复 }
|
||||
"""
|
||||
|
||||
# AFTER_SEND_REPLY = 5 # 发送回复后
|
||||
|
||||
|
||||
class EventAction(Enum):
|
||||
CONTINUE = 1 # 事件未结束,继续交给下个插件处理,如果没有下个插件,则交付给默认的事件处理逻辑
|
||||
BREAK = 2 # 事件结束,不再给下个插件处理,交付给默认的事件处理逻辑
|
||||
BREAK_PASS = 3 # 事件结束,不再给下个插件处理,不交付给默认的事件处理逻辑
|
||||
|
||||
|
||||
class EventContext:
|
||||
def __init__(self, event, econtext=dict()):
|
||||
self.event = event
|
||||
self.econtext = econtext
|
||||
self.action = EventAction.CONTINUE
|
||||
|
||||
def __getitem__(self, key):
|
||||
return self.econtext[key]
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
self.econtext[key] = value
|
||||
|
||||
def __delitem__(self, key):
|
||||
del self.econtext[key]
|
||||
|
||||
def is_pass(self):
|
||||
return self.action == EventAction.BREAK_PASS
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user