Compare commits

..

111 Commits

Author SHA1 Message Date
zhayujie
94004b095b fix: no debug config #744 2023-04-04 15:59:56 +08:00
lanvent
f652d592bd fix: typo in dequeue 2023-04-04 15:10:35 +08:00
lanvent
186e18fe94 godcmd: load clear_memory_commands 2023-04-04 14:58:51 +08:00
lanvent
28eb67bc24 feat: reset will cancel unprocessed messages 2023-04-04 14:57:38 +08:00
lanvent
6c7e4aaf37 feat: prioritize handling commands 2023-04-04 14:29:03 +08:00
lanvent
709a1317ef feat: add debug option 2023-04-04 14:02:14 +08:00
lanvent
371e38cfa6 add concurrency_in_session,request_timeout options 2023-04-04 13:33:01 +08:00
lanvent
5a221848e9 feat: avoid disorder by producer-consumer model 2023-04-04 05:18:09 +08:00
lanvent
7458a6298f feat: add trigger_by_self option 2023-04-03 23:58:19 +08:00
lanvent
b0f54bb8b7 fix: dirty message including at and prefix 2023-04-03 23:53:58 +08:00
lanvent
acddadc406 feat: add convert pcm32 to pcm16 2023-04-03 22:55:39 +08:00
lanvent
b74274b96b fix: old code in hello plugin 2023-04-03 02:00:33 +08:00
lanvent
49ba278316 fix: use english filename 2023-04-02 16:50:11 +08:00
lanvent
388058467c fix: delete same file twice 2023-04-02 14:55:45 +08:00
lanvent
cf25bd7869 feat: itchat show qrcode using viewer 2023-04-02 14:45:38 +08:00
lanvent
02a95345aa fix: add more qrcode api 2023-04-02 14:13:38 +08:00
lanvent
6076e2ed0a fix: voice longer than 60s cannot be sent 2023-04-02 12:29:10 +08:00
lanvent
cec674cb47 update qrcode 2023-04-02 04:44:08 +08:00
Jianglang
c5a90823fa Update README.md 2023-04-02 04:30:40 +08:00
Jianglang
18d82bc1f0 Update README.md 2023-04-02 04:23:13 +08:00
lanvent
a68af990ea update Readme.md 2023-04-02 04:19:50 +08:00
lanvent
e71c600d10 feat: new itchat qrcode generator 2023-04-02 03:46:09 +08:00
lanvent
d7f1f7182c feat: add always_reply_voice option 2023-04-01 22:27:11 +08:00
lanvent
dfb2e460b4 fix: voice length bug in wechaty 2023-04-01 21:58:55 +08:00
lanvent
5badef8ba9 fix: correct sample rate when convert to silk 2023-04-01 20:59:52 +08:00
lanvent
18aa5ce75c fix: get correct audio format in pytts 2023-04-01 20:58:06 +08:00
lanvent
1545a9f262 feat: support azure voice 2023-04-01 16:36:27 +08:00
Jianglang
47cc65a787 Merge pull request #720 from lanvent/master
wechaty支持插件
2023-04-01 05:01:31 +08:00
lanvent
cda9d5873d plugins: support wechaty channel 2023-04-01 04:26:34 +08:00
lanvent
02cd553990 refactor: using one processing logic in chat channel 2023-04-01 04:24:00 +08:00
lanvent
87df588c80 refactor: stripp processing unrelated to channel 2023-03-31 22:31:10 +08:00
lanvent
4ad2997717 compatibility: lower boolean values in env 2023-03-31 15:25:02 +08:00
lanvent
50a03e7c15 refactor: wrap wechaty message 2023-03-31 05:36:53 +08:00
lanvent
4f3d12129c refactor: wrap wechat msg to make logic general 2023-03-31 05:36:52 +08:00
lanvent
37a95980d4 feat: support at everywhere 2023-03-31 05:27:19 +08:00
lanvent
d9ef5a6612 fix: 无前缀触发bug 2023-03-30 18:26:44 +08:00
lanvent
66a81cd47c fix: 修复群语音触发bug 2023-03-30 16:26:01 +08:00
lanvent
81edd13470 Merge branch 'master' of https://github.com/zhayujie/chatgpt-on-wechat into master-dev 2023-03-30 16:07:29 +08:00
lanvent
7a94745b8a fix: group chat bug 2023-03-30 16:06:57 +08:00
zhanws
06b02f5df8 解决百度语音合成的一些问题和参数化设置 (#676)
* 解决百度语音合成的一些问题和参数化设置

* 补充百度语音说明
2023-03-30 14:59:52 +08:00
lanvent
83136e3142 feat: refactor handle function 2023-03-30 14:44:45 +08:00
lanvent
950a9f2ee0 docker: add Dockerfile 2023-03-30 02:13:03 +08:00
lanvent
a26c10fee8 feat: add git action for publish image 2023-03-30 02:04:30 +08:00
lanvent
4bcd76fe93 feat: update python in docker to 3.10 2023-03-30 00:32:40 +08:00
lanvent
90ccb091ca fix: change order in requirement.txt 2023-03-30 00:24:31 +08:00
lanvent
62df27eaa1 fix: retry when send failed 2023-03-30 00:23:57 +08:00
lanvent
349115b948 fix: use mp3 file when mp3_to_wav failed 2023-03-29 17:05:32 +08:00
lanvent
4fd7e4be67 fix: ignore remove file failed 2023-03-29 16:46:55 +08:00
lanvent
947e892916 feat: retry when timeout 2023-03-29 15:12:27 +08:00
lanvent
d62b7d1a99 feat: merge chat related sessions 2023-03-29 12:25:31 +08:00
lanvent
432b39a9c4 fix: single voice to text bug 2023-03-29 11:32:30 +08:00
zhanws
26540bfb63 补充语音单聊前缀判断过滤 (#661) 2023-03-29 01:41:36 +08:00
lanvent
fd64f88a7e fix: import openai.error 2023-03-28 22:18:29 +08:00
lanvent
72994bc9ef fix: voice to text bug 2023-03-28 18:56:36 +08:00
lanvent
7e1138af50 Merge branch 'master' of https://github.com/zhayujie/chatgpt-on-wechat into master-dev 2023-03-28 17:36:35 +08:00
lanvent
72dbddb7f7 sdwebui: add use_https startarg 2023-03-28 17:36:05 +08:00
Jianglang
10dba50843 Update ISSUE_TEMPLATE.md 2023-03-28 17:09:41 +08:00
lanvent
d6af1b5827 bdunit: update README.md 2023-03-28 15:27:37 +08:00
zhanws
6c362a9b4b 增加利用百度UNIT实现智能对话插件 (#642)
* 利用百度UNIT实现智能对话插件

* 更新参数

* 增加BDunit配置参数模板
2023-03-28 15:17:09 +08:00
lanvent
9a0584d649 Update README.md 2023-03-28 13:42:52 +08:00
Jianglang
5ab5211c95 Update ISSUE_TEMPLATE.md 2023-03-28 13:28:45 +08:00
zhayujie
f644682be7 fix: add voice dependency compatibility #641 2023-03-28 10:15:21 +08:00
lanvent
ffad8e4d26 feat: add railway template 2023-03-28 06:48:08 +08:00
lanvent
8f07e6304a fix: update requirement.txt 2023-03-28 05:56:26 +08:00
lanvent
834c03359f feat: support railway template 2023-03-28 05:28:28 +08:00
lanvent
3e2c68ba49 Merge branch 'zwssunny-master' into master 2023-03-28 03:20:41 +08:00
lanvent
2a21941b68 feat: modify requirement.txt 2023-03-28 03:16:05 +08:00
lanvent
e78886fb35 feat: new voice class pytts 2023-03-28 03:14:26 +08:00
lanvent
80bf6a0c7a Merge branch 'master' of github.com:zwssunny/chatgpt-on-wechat into zwssunny-master 2023-03-28 01:29:21 +08:00
Jianglang
48e066b677 Update readme.md 2023-03-28 01:26:27 +08:00
lanvent
dcb9d7fc2a fix: empty content issue 2023-03-28 01:16:29 +08:00
lanvent
279f0f0234 fix: incomplete qr code for railway 2023-03-28 01:13:29 +08:00
zwssunny
b3c8a7d8de check_prefix函数跑到外面了 2023-03-27 19:58:29 +08:00
zwssunny
1baf1a79e5 合并冲突 2023-03-27 19:38:19 +08:00
lanvent
35160e717e role: add roles 2023-03-27 19:35:56 +08:00
zhanws
a12f2d8fbd Merge branch 'master' into master 2023-03-27 19:27:46 +08:00
doublet44
6b7c17374b role: add new roles in roles.json (#618)
* 更新 roles.json

新增role角色
2023-03-27 19:03:18 +08:00
lanvent
9b3585e795 feat: support image create for group voice 2023-03-27 18:57:47 +08:00
lanvent
74f383a7d4 Merge pull request #629 from Chiaki-Chan/master
ItChat-uos方案下添加对群组语音消息的响应
2023-03-27 18:56:40 +08:00
zhanws
820fbeed18 Merge branch 'zhayujie:master' into master 2023-03-27 18:29:05 +08:00
zwssunny
f76e8d9a77 根据参数创建频道 2023-03-27 18:25:54 +08:00
zwssunny
5b85e60d5d 增加群组语言功能 2023-03-27 18:24:39 +08:00
zwssunny
24de670c2c 解决语音的识别和转换兼容性 2023-03-27 16:53:59 +08:00
Chiaki
42aca71763 1.更新redeme 2023-03-27 16:50:50 +08:00
Chiaki
9b4ef85174 Merge branch 'master' of github.com:Chiaki-Chan/chatgpt-on-wechat 2023-03-27 16:47:00 +08:00
Chiaki
9b389ffc33 1.itchat添加群组语音回复文本功能;2.itchat添加群组语音回复语音功能;3.更新redeme 2023-03-27 16:46:53 +08:00
zwssunny
b3cb81aa52 wx频道增加群语音聊天功能 2023-03-27 16:13:58 +08:00
zwssunny
61865bc408 修改google_voice为google合成,解决系统兼容性 2023-03-27 14:54:00 +08:00
zwssunny
c2ea6214a9 增加百度语音识别 2023-03-27 14:40:19 +08:00
zwssunny
b6684fe7a3 增加声音转换函数 2023-03-27 14:11:05 +08:00
lanvent
b50ebc05a0 fix: username not in itchat msg 2023-03-27 13:46:48 +08:00
lanvent
dbb0648c39 fix: typo in README.md 2023-03-27 02:30:44 +08:00
zhayujie
5fc0987cc3 Merge pull request #623 from Chiaki-Chan/master
Wechaty方案下添加对群组语音消息的响应
2023-03-27 01:42:00 +08:00
Chiaki
7c4037147c 1.wechaty添加群组语音回复文本功能;2.wechaty添加群组语音回复语音功能;3.更新config.py和readme; 2023-03-27 01:34:41 +08:00
zhayujie
f76cb1231e Merge pull request #621 from lanvent/dev2
refactor and support plugins for OpenAIBot
2023-03-27 01:32:59 +08:00
Chiaki
6701d8c5e6 1.wechaty添加群组语音回复文本功能;2.wechaty添加群组语音回复语音功能;3.更新config.py和readme; 2023-03-27 01:25:54 +08:00
lanvent
ff3d143185 plugins: support openaibot 2023-03-26 23:33:29 +08:00
lanvent
ea95ab9062 refactor: decouple openai session 2023-03-26 23:09:05 +08:00
lanvent
38c901a1c5 fix: init image api for bot 2023-03-26 21:49:07 +08:00
lanvent
0c9753b7cd refactor: decouple chatgpt session 2023-03-26 21:46:33 +08:00
lanvent
721b36c7f7 refactor: reuse openai image interface 2023-03-26 20:08:04 +08:00
zhayujie
f8e0716474 Merge pull request #614 from lanvent/dev2
feat: support calc tokens precisely
2023-03-26 19:56:49 +08:00
lanvent
3d428ee844 fix: avoid possible dead loop when discarding 2023-03-26 18:07:28 +08:00
lanvent
a3be1fcd8f feat: support calc tokens precisely 2023-03-26 17:49:49 +08:00
lanvent
167f10c9f9 plugins : provide help url when plugin init failed 2023-03-26 15:27:15 +08:00
lanvent
b3cabd9621 fix: restore the original sleep time in itchat 2023-03-26 15:12:27 +08:00
lanvent
709468d281 fix: wechaty voice_to_text 2023-03-26 14:58:20 +08:00
lanvent
c3a2bd70ff docker: remove unused command 2023-03-26 14:57:56 +08:00
lanvent
92caeed7ab feat: intergrate itchat to lib 2023-03-26 13:35:46 +08:00
zhayujie
3c91575ebe Merge pull request #605 from lanvent/dev2
docker: use environment vars  to set parameters
2023-03-26 11:51:23 +08:00
lanvent
46a6223a43 docker: use environment args to set parameters 2023-03-26 03:31:29 +08:00
82 changed files with 6873 additions and 956 deletions

View File

@@ -1,9 +1,11 @@
### 前置确认
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. 在已有 issue 中未搜索到类似问题
6. [FAQS](https://github.com/zhayujie/chatgpt-on-wechat/wiki/FAQs) 中无类似问题
### 问题描述

59
.github/workflows/deploy-image.yml vendored Normal file
View 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 }}

1
.gitignore vendored
View File

@@ -10,3 +10,4 @@ nohup.out
tmp
plugins.json
itchat.pkl
*.log

3
Dockerfile Normal file
View File

@@ -0,0 +1,3 @@
FROM ghcr.io/zhayujie/chatgpt-on-wechat:latest
ENTRYPOINT ["/entrypoint.sh"]

View File

@@ -2,7 +2,7 @@
> ChatGPT近期以强大的对话和信息整合能力风靡全网可以写代码、改论文、讲故事几乎无所不能这让人不禁有个大胆的想法能否用他的对话模型把我们的微信打造成一个智能机器人可以在与好友对话中给出意想不到的回应而且再也不用担心女朋友影响我们 ~~打游戏~~ 工作了。
基于ChatGPT的微信聊天机器人通过 [ChatGPT](https://github.com/openai/openai-python) 接口生成对话内容,使用 [itchat](https://github.com/littlecodersh/ItChat) 实现微信消息的接收和自动回复。已实现的特性如下:
- [x] **文本对话:** 接收私聊及群组中的微信消息使用ChatGPT生成回复内容完成自动回复
@@ -11,7 +11,11 @@
- [x] **图片生成:** 支持根据描述生成图片,并自动发送至个人聊天或群聊
- [x] **上下文记忆**:支持多轮对话记忆,且为每个好友维护独立的上下会话
- [x] **语音识别:** 支持接收和处理语音消息,通过文字或语音回复
- [x] **插件化:** 支持个性化功能插件,提供角色扮演、文字冒险游戏等预设插件
> 快速部署:
>
>[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/template/qApznZ?referralCode=RC3znh)
# 更新日志
@@ -60,7 +64,7 @@
### 2.运行环境
支持 Linux、MacOS、Windows 系统可在Linux服务器上长期运行),同时需安装 `Python`
支持 Linux、MacOS、Windows 系统可在Linux服务器上长期运行),同时需安装 `Python`
> 建议Python版本在 3.7.1~3.9.X 之间3.10及以上版本在 MacOS 可用,其他系统上不确定能否正常运行。
**(1) 克隆项目代码:**
@@ -73,15 +77,25 @@ cd chatgpt-on-wechat/
**(2) 安装核心依赖 (必选)**
```bash
pip3 install itchat-uos==1.5.0.dev0
pip3 install --upgrade openai
pip3 install -r requirements.txt
```
注:`itchat-uos`使用指定版本1.5.0.dev0`openai`使用最新版本需高于0.27.0。
其中`tiktoken`要求`python`版本在3.8以上它用于精确计算会话使用的tokens数量可以不装但建议安装。
**(3) 拓展依赖 (可选)**
语音识别及语音回复相关依赖:[#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`语音功能需安装依赖:
```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)
## 配置
@@ -107,6 +121,7 @@ pip3 install --upgrade openai
"image_create_prefix": ["画", "看", "找"], # 开启图片回复的前缀
"conversation_max_tokens": 1000, # 支持上下文记忆的最多字符数
"speech_recognition": false, # 是否开启语音识别
"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训练的大型语言模型, 你旨在回答并解决人们的任何问题,并且可以使用多种语言与人交流。", # 人格描述,
}
@@ -127,8 +142,9 @@ pip3 install --upgrade openai
**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.其他配置**
@@ -143,6 +159,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)中列出。**
## 运行
@@ -177,14 +194,11 @@ nohup python3 app.py & tail -f nohup.out # 在后台运行程序并通
参考文档 [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部署
[Use with Railway](#use-with-railway)(PaaS, Free, Stable, ✅Recommended)
> Railway offers $5 (500 hours) of runtime per month
1. Click the [Railway](https://railway.app/) button to go to the Railway homepage
2. Click the `Start New Project` button.
3. Click the `Deploy from Github repo` button.
4. Choose your repo (you can fork this repo firstly)
5. Set environment variable to override settings in config-template.json, such as: model, open_ai_api_base, open_ai_api_key, use_azure_chatgpt etc.
### 4. Railway部署(✅推荐)
> Railway每月提供5刀和最多500小时的免费额度。
1. 进入 [Railway](https://railway.app/template/qApznZ?referralCode=RC3znh)。
2. 点击 `Deploy Now` 按钮。
3. 设置环境变量来重载程序运行的参数,例如`open_ai_api_key`, `character_desc`
## 常见问题

13
app.py
View File

@@ -1,6 +1,7 @@
# encoding:utf-8
import config
import os
from config import conf, load_config
from channel import channel_factory
from common.log import logger
@@ -9,12 +10,16 @@ from plugins import *
def run():
try:
# load config
config.load_config()
load_config()
# create channel
channel_name='wx'
channel_name=conf().get('channel_type', 'wx')
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=='wx':
if channel_name in ['wx','wxy']:
PluginManager().load_plugins()
# startup channel

View File

@@ -6,9 +6,9 @@ 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对话接口

View File

@@ -1,6 +1,9 @@
# encoding:utf-8
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 Session, SessionManager
from bridge.context import ContextType
from bridge.reply import Reply, ReplyType
from config import conf, load_config
@@ -8,28 +11,27 @@ from common.log import logger
from common.token_bucket import TokenBucket
from common.expired_dict import ExpiredDict
import openai
import openai.error
import time
# OpenAI对话模型API (可用)
class ChatGPTBot(Bot):
class ChatGPTBot(Bot,OpenAIImage):
def __init__(self):
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')
self.sessions = SessionManager()
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))
self.sessions = SessionManager(ChatGPTSession, model= conf().get("model") or "gpt-3.5-turbo")
def reply(self, query, context=None):
# acquire reply content
if context.type == ContextType.TEXT:
logger.info("[OPEN_AI] query={}".format(query))
logger.info("[CHATGPT] query={}".format(query))
session_id = context['session_id']
reply = None
@@ -45,23 +47,23 @@ class ChatGPTBot(Bot):
reply = Reply(ReplyType.INFO, '配置已更新')
if reply:
return reply
session = self.sessions.build_session_query(query, session_id)
logger.debug("[OPEN_AI] session query={}".format(session))
session = self.sessions.session_query(query, session_id)
logger.debug("[CHATGPT] session query={}".format(session.messages))
# 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"]))
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.save_session(reply_content["content"], session_id, reply_content["total_tokens"])
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("[OPEN_AI] reply {} used 0 tokens.".format(reply_content))
logger.debug("[CHATGPT] reply {} used 0 tokens.".format(reply_content))
return reply
elif context.type == ContextType.IMAGE_CREATE:
@@ -84,9 +86,10 @@ class ChatGPTBot(Bot):
"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', 30), # 请求超时时间
}
def reply_text(self, session, session_id, retry_count=0) -> dict:
def reply_text(self, session:ChatGPTSession, session_id, retry_count=0) -> dict:
'''
call openai's ChatCompletion to get the answer
:param session: a conversation session
@@ -96,62 +99,41 @@ class ChatGPTBot(Bot):
'''
try:
if conf().get('rate_limit_chatgpt') and not self.tb4chatgpt.get_token():
return {"completion_tokens": 0, "content": "提问太快啦,请休息一下再问我吧"}
raise openai.error.RateLimitError("RateLimitError: rate limit exceeded")
response = openai.ChatCompletion.create(
messages=session, **self.compose_args()
messages=session.messages, **self.compose_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))
except Exception as e:
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(5)
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:
logger.warn("[CHATGPT] Exception: {}".format(e))
need_retry = False
self.sessions.clear_session(session_id)
if need_retry:
logger.warn("[CHATGPT] 第{}次重试".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": "我没有收到你的消息"}
except Exception as e:
# unknown exception
logger.exception(e)
self.sessions.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 False, "请求太快了,请休息一下再问我吧"
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 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)
return result
class AzureChatGPTBot(ChatGPTBot):
@@ -164,75 +146,4 @@ class AzureChatGPTBot(ChatGPTBot):
args = super().compose_args()
args["engine"] = args["model"]
del(args["model"])
return args
class SessionManager(object):
def __init__(self):
if conf().get('expires_in_seconds'):
sessions = ExpiredDict(conf().get('expires_in_seconds'))
else:
sessions = dict()
self.sessions = sessions
def build_session(self, session_id, system_prompt=None):
session = self.sessions.get(session_id, [])
if len(session) == 0:
if system_prompt is None:
system_prompt = conf().get("character_desc", "")
system_item = {'role': 'system', 'content': system_prompt}
session.append(system_item)
self.sessions[session_id] = session
return session
def build_session_query(self, 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 = self.build_session(session_id)
user_item = {'role': 'user', 'content': query}
session.append(user_item)
return session
def save_session(self, 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 = self.sessions.get(session_id)
if session:
# append conversation
gpt_item = {'role': 'assistant', 'content': answer}
session.append(gpt_item)
# discard exceed limit conversation
self.discard_exceed_conversation(session, max_tokens, total_tokens)
def discard_exceed_conversation(self, 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)
else:
break
dec_tokens = dec_tokens - max_tokens
def clear_session(self, session_id):
self.sessions[session_id] = []
def clear_all_session(self):
self.sessions.clear()
return args

View File

@@ -0,0 +1,79 @@
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 = num_tokens_from_messages(self.messages, self.model)
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 = num_tokens_from_messages(self.messages, self.model)
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 = num_tokens_from_messages(self.messages, self.model)
else:
cur_tokens = cur_tokens - max_tokens
return cur_tokens
# 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":
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

View File

@@ -1,18 +1,23 @@
# encoding:utf-8
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 config import conf
from common.log import logger
import openai
import openai.error
import time
user_session = dict()
# OpenAI对话模型API (可用)
class OpenAIBot(Bot):
class OpenAIBot(Bot, OpenAIImage):
def __init__(self):
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')
@@ -20,34 +25,45 @@ class OpenAIBot(Bot):
if proxy:
openai.proxy = proxy
self.sessions = SessionManager(OpenAISession, model= conf().get("model") or "text-davinci-003")
def reply(self, query, context=None):
# acquire reply content
if context and context.type:
if context.type == ContextType.TEXT:
logger.info("[OPEN_AI] query={}".format(query))
from_user_id = context['session_id']
session_id = context['session_id']
reply = None
if query == '#清除记忆':
Session.clear_session(from_user_id)
self.sessions.clear_session(session_id)
reply = Reply(ReplyType.INFO, '记忆已清除')
elif query == '#清除所有':
Session.clear_all_session()
self.sessions.clear_all_session()
reply = Reply(ReplyType.INFO, '所有人记忆已清除')
else:
new_query = Session.build_session_query(query, from_user_id)
session = self.sessions.session_query(query, session_id)
new_query = str(session)
logger.debug("[OPEN_AI] session query={}".format(new_query))
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)
reply = Reply(ReplyType.TEXT, reply_content)
total_tokens, completion_tokens, reply_content = self.reply_text(new_query, session_id, 0)
logger.debug("[OPEN_AI] new_query={}, session_id={}, reply_cont={}, completion_tokens={}".format(new_query, session_id, reply_content, completion_tokens))
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:
return self.create_img(query, 0)
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
def reply_text(self, query, user_id, retry_count=0):
def reply_text(self, query, session_id, retry_count=0):
try:
response = openai.Completion.create(
model= conf().get("model") or "text-davinci-003", # 对话模型的名称
@@ -60,116 +76,34 @@ class OpenAIBot(Bot):
stop=["\n\n\n"]
)
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, completion_tokens, 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 = [0,0,"我现在有点累了,等会再来吧"]
if isinstance(e, openai.error.RateLimitError):
logger.warn("[OPEN_AI] RateLimitError: {}".format(e))
result[2] = "提问太快啦,请休息一下再问我吧"
if need_retry:
time.sleep(5)
elif isinstance(e, openai.error.Timeout):
logger.warn("[OPEN_AI] Timeout: {}".format(e))
result[2] = "我没有收到你的消息"
if need_retry:
time.sleep(5)
elif isinstance(e, openai.error.APIConnectionError):
logger.warn("[OPEN_AI] APIConnectionError: {}".format(e))
need_retry = False
result[2] = "我连接不到你的网络"
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_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(query, session_id, retry_count+1)
else:
return result

View File

@@ -0,0 +1,38 @@
import time
import openai
import openai.error
from common.token_bucket import TokenBucket
from common.log import logger
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="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)

View File

@@ -0,0 +1,67 @@
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 = num_tokens_from_string(str(self), self.model)
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 = num_tokens_from_string(str(self), self.model)
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 = num_tokens_from_string(str(self), self.model)
else:
cur_tokens = len(str(self))
return cur_tokens
# 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

85
bot/session_manager.py Normal file
View File

@@ -0,0 +1,85 @@
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
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 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()

View File

@@ -14,7 +14,7 @@ class Bridge(object):
self.btype={
"chat": const.CHATGPT,
"voice_to_text": conf().get("voice_to_text", "openai"),
"text_to_voice": conf().get("text_to_voice", "baidu")
"text_to_voice": conf().get("text_to_voice", "google")
}
model_type = conf().get("model")
if model_type in ["text-davinci-003"]:

View File

@@ -14,6 +14,15 @@ class Context:
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
@@ -21,6 +30,12 @@ class Context:
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':

View File

@@ -20,7 +20,8 @@ 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

310
channel/chat_channel.py Normal file
View File

@@ -0,0 +1,310 @@
from asyncio import CancelledError
from concurrent.futures import Future, ThreadPoolExecutor
import os
import re
import threading
import time
from common.dequeue import Dequeue
from channel.channel import Channel
from bridge.reply import *
from bridge.context import *
from config import conf
from common.log import logger
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 cmsg.from_user_id == self.user_id and not config.get('trigger_by_self', False):
logger.debug("[WX]self message skipped")
return None
if context["isgroup"]:
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
# 消息内容匹配过程并处理content
if ctype == ContextType.TEXT:
if first_in and "\n- - - - - - -" in content: # 初次匹配 过滤引用消息
logger.debug("[WX]reference query skipped")
return None
if context["isgroup"]: # 群聊
# 校验关键字
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'@{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).strip()
context.type = ContextType.IMAGE_CREATE
else:
context.type = ContextType.TEXT
context.content = content
if 'desire_rtype' not in context and conf().get('always_reply_voice'):
context['desire_rtype'] = ReplyType.VOICE
elif context.type == ContextType.VOICE:
if 'desire_rtype' not in context and conf().get('voice_reply_voice'):
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对于某些apimp3也可以识别
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
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 == ReplyType.TEXT:
reply_text = reply.content
if 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
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 thread_pool_callback(self, session_id):
def func(worker:Future):
try:
worker_exception = worker.exception()
if worker_exception:
logger.exception("Worker return exception: {}".format(worker_exception))
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", 1))]
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))
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):
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

83
channel/chat_message.py Normal file
View File

@@ -0,0 +1,83 @@
"""
本类表示聊天消息用于对itchat和wechaty的消息进行统一的封装
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,
)

View File

@@ -5,70 +5,119 @@ wechat channel
"""
import os
import itchat
import json
from itchat.content import *
from bridge.reply import *
from bridge.context 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
from common.time_check import time_checker
from plugins import *
import threading
import requests
import io
import time
thread_pool = ThreadPoolExecutor(max_workers=8)
def thread_pool_callback(worker):
worker_exception = worker.exception()
if worker_exception:
logger.exception("Worker return exception: {}".format(worker_exception))
import json
from channel.chat_channel import ChatChannel
from channel.wechat.wechat_message import *
from common.singleton import singleton
from common.log import logger
from lib import itchat
from lib.itchat.content import *
from bridge.reply import *
from bridge.context import *
from config import conf
from common.time_check import time_checker
from common.expired_dict import ExpiredDict
from plugins import *
@itchat.msg_register(TEXT)
def handler_single_msg(msg):
WechatChannel().handle_text(msg)
WechatChannel().handle_text(WeChatMessage(msg))
return None
@itchat.msg_register(TEXT, isGroupChat=True)
def handler_group_msg(msg):
WechatChannel().handle_group(msg)
WechatChannel().handle_group(WeChatMessage(msg,True))
return None
@itchat.msg_register(VOICE)
def handler_single_voice(msg):
WechatChannel().handle_voice(msg)
WechatChannel().handle_voice(WeChatMessage(msg))
return None
@itchat.msg_register(VOICE, isGroupChat=True)
def handler_group_voice(msg):
WechatChannel().handle_group_voice(WeChatMessage(msg,True))
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):
def __init__(self):
pass
super().__init__()
self.receivedMsgs = ExpiredDict(60*60*24)
def startup(self):
itchat.instance.receivingRetryCount = 600 # 修改断线超时时间
itchat.instance.receivingRetryCount = 600 # 修改断线超时时间
# login by scan QRCode
hotReload = conf().get('hot_reload', False)
try:
itchat.auto_login(enableCmdQR=2, hotReload=hotReload)
itchat.auto_login(enableCmdQR=2, hotReload=hotReload, qrCallback=qrCallback)
except Exception as e:
if hotReload:
logger.error("Hot reload failed, try to login without hot reload")
itchat.logout()
os.remove("itchat.pkl")
itchat.auto_login(enableCmdQR=2, hotReload=hotReload)
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()
# handle_* 系列函数处理收到的消息后构造Context然后传入handle函数中处理Context和发送回复
# handle_* 系列函数处理收到的消息后构造Context然后传入_handle函数中处理Context和发送回复
# Context包含了消息的所有信息包括以下属性
# type 消息类型, 包括TEXT、VOICE、IMAGE_CREATE
# content 消息内容如果是TEXT类型content就是文本内容如果是VOICE类型content就是语音文件名如果是IMAGE_CREATE类型content就是图片生成命令
@@ -76,101 +125,49 @@ class WechatChannel(Channel):
# session_id: 会话id
# isgroup: 是否是群聊
# receiver: 需要回复的对象
# msg: itchat的原始消息对象
# msg: ChatMessage消息对象
# origin_ctype: 原始消息类型,语音转文字后,私聊时如果匹配前缀失败,会根据初始消息是否是语音来放宽触发规则
# desire_rtype: 希望回复类型默认是文本回复设置为ReplyType.VOICE是语音回复
def handle_voice(self, msg):
@time_checker
@_check
def handle_voice(self, cmsg : ChatMessage):
if conf().get('speech_recognition') != True:
return
logger.debug("[WX]receive voice msg: " + msg['FileName'])
from_user_id = msg['FromUserName']
other_user_id = msg['User']['UserName']
if from_user_id == other_user_id:
context = Context(ContextType.VOICE,msg['FileName'])
context.kwargs = {'isgroup': False, 'msg': msg, 'receiver': other_user_id, 'session_id': other_user_id}
thread_pool.submit(self.handle, context).add_done_callback(thread_pool_callback)
logger.debug("[WX]receive voice msg: {}".format(cmsg.content))
context = self._compose_context(ContextType.VOICE, cmsg.content, isgroup=False, msg=cmsg)
if context:
self.produce(context)
@time_checker
def handle_text(self, msg):
logger.debug("[WX]receive text msg: " + json.dumps(msg, ensure_ascii=False))
content = msg['Text']
from_user_id = msg['FromUserName']
to_user_id = msg['ToUserName'] # 接收人id
other_user_id = msg['User']['UserName'] # 对手方id
create_time = msg['CreateTime'] # 消息时间
match_prefix = 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 match_prefix:
content = content.replace(match_prefix, '', 1).strip()
elif match_prefix is None:
return
context = Context()
context.kwargs = {'isgroup': False, 'msg': msg, 'receiver': other_user_id, 'session_id': other_user_id}
img_match_prefix = check_prefix(content, conf().get('image_create_prefix'))
if img_match_prefix:
content = content.replace(img_match_prefix, '', 1).strip()
context.type = ContextType.IMAGE_CREATE
else:
context.type = ContextType.TEXT
context.content = content
thread_pool.submit(self.handle, context).add_done_callback(thread_pool_callback)
@_check
def handle_text(self, cmsg : ChatMessage):
logger.debug("[WX]receive text msg: {}, cmsg={}".format(json.dumps(cmsg._rawmsg, ensure_ascii=False), cmsg))
context = self._compose_context(ContextType.TEXT, cmsg.content, isgroup=False, msg=cmsg)
if context:
self.produce(context)
@time_checker
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")
@_check
def handle_group(self, cmsg : ChatMessage):
logger.debug("[WX]receive group msg: {}, cmsg={}".format(json.dumps(cmsg._rawmsg, ensure_ascii=False), cmsg))
context = self._compose_context(ContextType.TEXT, cmsg.content, isgroup=True, msg=cmsg)
if context:
self.produce(context)
@time_checker
@_check
def handle_group_voice(self, cmsg : ChatMessage):
if conf().get('group_speech_recognition', False) != True:
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 check_prefix(origin_content, config.get('group_chat_prefix')) \
or 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 check_contain(group_name, config.get('group_name_keyword_white_list'))) and match_prefix:
context = Context()
context.kwargs = { 'isgroup': True, 'msg': msg, 'receiver': group_id}
img_match_prefix = check_prefix(content, conf().get('image_create_prefix'))
if img_match_prefix:
content = content.replace(img_match_prefix, '', 1).strip()
context.type = ContextType.IMAGE_CREATE
else:
context.type = ContextType.TEXT
context.content = content
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
check_contain(group_name, group_chat_in_one_session)):
context['session_id'] = group_id
else:
context['session_id'] = msg['ActualUserName']
thread_pool.submit(self.handle, context).add_done_callback(thread_pool_callback)
logger.debug("[WX]receive voice for group msg: {}".format(cmsg.content))
context = self._compose_context(ContextType.VOICE, cmsg.content, isgroup=True, msg=cmsg)
if context:
self.produce(context)
# 统一的发送函数每个Channel自行实现根据reply的type字段发送不同类型的消息
def send(self, reply : Reply, receiver):
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))
@@ -194,79 +191,3 @@ class WechatChannel(Channel):
image_storage.seek(0)
itchat.send_image(image_storage, toUserName=receiver)
logger.info('[WX] sendImage, receiver={}'.format(receiver))
# 处理消息 TODO: 如果wechaty解耦此处逻辑可以放置到父类
def handle(self, context):
reply = Reply()
logger.debug('[WX] ready to handle context: {}'.format(context))
# 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:
msg = context['msg']
file_name = TmpDir().path() + context.content
msg.download(file_name)
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)
else:
logger.error('[WX] unknown context type: {}'.format(context.type))
return
logger.debug('[WX] ready to decorate reply: {}'.format(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']
if not e_context.is_pass() and reply and reply.type:
if reply.type == ReplyType.TEXT:
reply_text = reply.content
if context['isgroup']:
reply_text = '@' + context['msg']['ActualNickName'] + ' ' + 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
# 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: {} to {}'.format(reply, context['receiver']))
self.send(reply, context['receiver'])
def check_prefix(content, prefix_list):
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

View File

@@ -0,0 +1,57 @@
from bridge.context import ContextType
from channel.chat_message import ChatMessage
from common.tmp_dir import TmpDir
from common.log import logger
from lib.itchat.content import *
from lib import itchat
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)
else:
raise NotImplementedError("Unsupported message type: {}".format(itchat_msg['Type']))
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']
self.actual_user_nickname = itchat_msg['ActualNickName']

View File

@@ -4,289 +4,122 @@
wechaty channel
Python Wechaty - https://github.com/wechaty/python-wechaty
"""
import io
import base64
import os
import json
import time
import asyncio
import requests
import pysilk
import wave
from pydub import AudioSegment
from typing import Optional, Union
from bridge.context import Context, ContextType
from wechaty_puppet import MessageType, FileBox, ScanStatus # type: ignore
from bridge.context import Context
from wechaty_puppet import FileBox
from wechaty import Wechaty, Contact
from wechaty.user import Message, Room, MiniProgram, UrlLink
from channel.channel import Channel
from wechaty.user import Message
from bridge.reply import *
from bridge.context 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):
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):
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 = Context(ContextType.TEXT, query)
context['session_id'] = reply_user_id
reply_text = super().build_reply_content(query, context).content
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 = Context(ContextType.TEXT, query)
context['session_id'] = reply_user_id
reply_text = super().build_reply_content(query, context).content
if reply_text:
# 转换 mp3 文件为 silk 格式
mp3_file = super().build_text_to_voice(reply_text).content
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 = Context(ContextType.IMAGE_CREATE, query)
img_url = super().build_reply_content(query, context).content
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 = Context(ContextType.TEXT, query)
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).content
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 = Context(ContextType.IMAGE_CREATE, query)
img_url = super().build_reply_content(query, context).content
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)

View File

@@ -0,0 +1,85 @@
import asyncio
import re
from wechaty import MessageType
from bridge.context import ContextType
from channel.chat_message import ChatMessage
from common.tmp_dir import TmpDir
from common.log import logger
from wechaty.user import Message
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和towechaty跟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'@{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

33
common/dequeue.py Normal file
View 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)

View File

@@ -8,6 +8,10 @@ def _get_logger():
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'))
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)
return log

View File

@@ -10,6 +10,7 @@
"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,

135
config.py
View File

@@ -1,74 +1,92 @@
# encoding:utf-8
import json
import logging
import os
from common.log import logger
# 将所有可用的配置项写在字典里
available_setting ={
#openai api配置
"open_ai_api_key": "", # openai api key
"open_ai_api_base": "https://api.openai.com/v1", # openai apibase当use_azure_chatgpt为true时需要设置对应的api base
"proxy": "", # openai使用的代理
"model": "gpt-3.5-turbo", # chatgpt模型 当use_azure_chatgpt为true时其名称为Azure上model deployment名称
"use_azure_chatgpt": False, # 是否使用azure的chatgpt
# 将所有可用的配置项写在字典里, 请使用小写字母
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
#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测试群"], # 支持会话上下文共享的群名称
"image_create_prefix": ["", "", ""], # 开启图片回复的前缀
#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的调用频率限制
# 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可能乱序
# chatgpt会话参数
"expires_in_seconds": 3600, # 无操作会话的过期时间
"character_desc": "你是ChatGPT, 一个由OpenAI训练的大型语言模型, 你旨在回答并解决人们的任何问题,并且可以使用多种语言与人交流。", # 人格描述
"conversation_max_tokens": 1000, # 支持上下文记忆的最多字符数
#chatgpt api参数 参考https://platform.openai.com/docs/api-reference/chat/create
# 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": 30, # chatgpt请求超时时间
#语音设置
"speech_recognition": False, # 是否开启语音识别
"voice_reply_voice": False, # 是否使用语音回复语音需要设置对应语音合成引擎的api key
"voice_to_text": "openai", # 语音识别引擎支持openai和google
"text_to_voice": "baidu", # 语音合成引擎支持baidu和google
# 语音设置
"speech_recognition": False, # 是否开启语音识别
"group_speech_recognition": False, # 是否开启群组语音识别
"voice_reply_voice": False, # 是否使用语音回复语音需要设置对应语音合成引擎的api key
"always_reply_voice": False, # 是否一直使用语音回复
"voice_to_text": "openai", # 语音识别引擎支持openai,google,azure
"text_to_voice": "baidu", # 语音合成引擎支持baidu,google,pytts(offline),azure
# baidu api配置, 使用百度语音识别和语音合成时需要
'baidu_app_id': "",
'baidu_api_key': "",
'baidu_secret_key': "",
# baidu 语音api配置 使用百度语音识别和语音合成时需要
"baidu_app_id": "",
"baidu_api_key": "",
"baidu_secret_key": "",
# 1536普通话(支持简单的英文识别) 1737英语 1637粤语 1837四川话 1936普通话远场
"baidu_dev_pid": "1536",
#服务时间限制目前支持itchat
"chat_time_module": False, # 是否开启服务时间限制
"chat_start_time": "00:00", # 服务开始时间
"chat_stop_time": "24:00", # 服务结束时间
# 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, # 是否开启热重载
"hot_reload": False, # 是否开启热重载
# wechaty的配置
"wechaty_puppet_service_token": "", # wechaty的token
"wechaty_puppet_service_token": "", # wechaty的token
# chatgpt指令自定义触发词
"clear_memory_commands": ['#清除记忆'], # 重置会话指令
"clear_memory_commands": ['#清除记忆'], # 重置会话指令,必须以#开头
# channel配置
"channel_type": "wx", # 通道类型支持wx,wxy和terminal
"debug": False, # 是否开启debug模式开启后会打印更多日志
}
class Config(dict):
def __getitem__(self, key):
if key not in available_setting:
@@ -81,15 +99,17 @@ class Config(dict):
return super().__setitem__(key, value)
def get(self, key, default=None):
try :
try:
return self[key]
except KeyError as e:
return default
except Exception as e:
raise e
config = Config()
def load_config():
global config
config_path = "./config.json"
@@ -106,16 +126,29 @@ def load_config():
# 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))
config[name] = value
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))
def get_root():
return os.path.dirname(os.path.abspath( __file__ ))
return os.path.dirname(os.path.abspath(__file__))
def read_file(path):

View File

@@ -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,8 @@ 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 \
&& apk del curl wget
WORKDIR ${BUILD_PREFIX}

View File

@@ -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,8 @@ 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
WORKDIR ${BUILD_PREFIX}

View File

@@ -0,0 +1,32 @@
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 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"]

View File

@@ -1,35 +1,28 @@
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
COPY chatgpt-on-wechat.tar.gz ./chatgpt-on-wechat.tar.gz
ADD . ${BUILD_PREFIX}
RUN apk add --no-cache \
bash \
&& tar -xf chatgpt-on-wechat.tar.gz \
&& mv chatgpt-on-wechat ${BUILD_PREFIX} \
RUN apk add --no-cache bash ffmpeg espeak \
&& 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 \
&& cp config-template.json 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
WORKDIR ${BUILD_PREFIX}
ADD ./entrypoint.sh /entrypoint.sh
ADD docker/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
ENTRYPOINT ["/entrypoint.sh"]
ENTRYPOINT ["docker/entrypoint.sh"]

View File

@@ -1,8 +1,4 @@
#!/bin/bash
# move chatgpt-on-wechat
tar -zcf chatgpt-on-wechat.tar.gz --exclude=../../chatgpt-on-wechat/docker ../../chatgpt-on-wechat
# build image
docker build -f Dockerfile.alpine \
cd .. && docker build -f docker/Dockerfile.latest \
-t zhayujie/chatgpt-on-wechat .

View File

@@ -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

View File

@@ -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

96
lib/itchat/__init__.py Normal file
View 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

View 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)

View 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)

View 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.')

View 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, }})

View 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)

View 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()

View 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)

View 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)

View 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.')

View 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, }})

View 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)

View 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
View 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
View 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
View 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
View 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

View 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'请求成功',
},
}

View 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

View 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__())

View 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
View 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

View File

@@ -1,5 +0,0 @@
# entry point for online railway deployment
from app import run
if __name__ == '__main__':
run()

7
nixpacks.toml Normal file
View File

@@ -0,0 +1,7 @@
providers = ['python']
[phases.setup]
nixPkgs = ['python310']
cmds = ['apt-get update','apt-get install -y --no-install-recommends ffmpeg espeak']
[start]
cmd = "python ./app.py"

View File

@@ -1,6 +1,6 @@
## 插件化初衷
之前未插件化的代码耦合程度高,如果要定制一些个性化功能(如流量控制、接入`NovelAI`画图平台等),需要了解代码主体,避免影响到其他的功能。在实现多个功能后,不但无法调整功能的优先级顺序,功能配置项也会变得非常混乱。
之前未插件化的代码耦合程度高,如果要定制一些个性化功能(如流量控制、接入`NovelAI`画图平台等),需要了解代码主体,避免影响到其他的功能。多个功能同时存在时,无法调整功能的优先级顺序,功能配置项也非常混乱。
此时插件化应声而出。
@@ -11,7 +11,7 @@
- [x] 插件化能够自由开关和调整优先级。
- [x] 每个插件可在插件文件夹内维护独立的配置文件,方便代码的测试和调试,可以在独立的仓库开发插件。
PS: 插件目前支持`itchat`
PS: 插件目前支持`itchat``wechaty`
## 插件化实现
@@ -101,7 +101,7 @@ PS: 插件目前仅支持`itchat`
根据`Context`和回复`Reply`的类型,对回复的内容进行装饰。目前的装饰有以下两种:
- `TEXT`文本回复根据是否在群聊中来决定是艾特接收方还是添加回复的前缀。
- `TEXT`文本回复:如果这次消息需要的回复是`VOICE`,进行文字转语音回复之后再次装饰。 否则根据是否在群聊中来决定是艾特接收方还是添加回复的前缀。
- `INFO``ERROR`类型,会在消息前添加对应的系统提示字样。
@@ -110,8 +110,11 @@ PS: 插件目前仅支持`itchat`
```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']['ActualNickName'] + ' ' + reply_text.strip()
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
@@ -213,11 +216,11 @@ class Hello(Plugin):
if content == "Hello":
reply = Reply()
reply.type = ReplyType.TEXT
msg = e_context['context']['msg']
msg:ChatMessage = e_context['context']['msg']
if e_context['context']['isgroup']:
reply.content = "Hello, " + msg['ActualNickName'] + " from " + msg['User'].get('NickName', "Group")
reply.content = f"Hello, {msg.actual_user_nickname} from {msg.from_user_nickname}"
else:
reply.content = "Hello, " + msg['User'].get('NickName', "My friend")
reply.content = f"Hello, {msg.from_user_nickname}"
e_context['reply'] = reply
e_context.action = EventAction.BREAK_PASS # 事件结束并跳过处理context的默认逻辑
if content == "End":

View File

@@ -38,7 +38,7 @@ class Banwords(Plugin):
self.handlers[Event.ON_HANDLE_CONTEXT] = self.on_handle_context
logger.info("[Banwords] inited")
except Exception as e:
logger.warn("Banwords init failed: %s" % e)
logger.warn("Banwords init failed: %s, ignore or see https://github.com/zhayujie/chatgpt-on-wechat/tree/master/plugins/banwords ." % e)

30
plugins/bdunit/README.md Normal file
View 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": ""
}
```

View File

294
plugins/bdunit/bdunit.py Normal file
View File

@@ -0,0 +1,294 @@
# encoding:utf-8
import json
import os
import uuid
import requests
from bridge.context import ContextType
from bridge.reply import Reply, ReplyType
from common.log import logger
import plugins
from plugins import *
from uuid import getnode as get_mac
"""利用百度UNIT实现智能对话
如果命中意图,返回意图对应的回复,否则返回继续交付给下个插件处理
"""
@plugins.register(name="BDunit", desc="Baidu unit bot system", version="0.1", author="jackson", desire_priority=0)
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: %s, ignore " % 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 ""

View File

@@ -0,0 +1,5 @@
{
"service_id": "s...",
"api_key": "",
"secret_key": ""
}

View File

@@ -52,7 +52,7 @@ class Dungeon(Plugin):
if e_context['context'].type != ContextType.TEXT:
return
bottype = Bridge().get_bot_type("chat")
if bottype != const.CHATGPT:
if bottype not in (const.CHATGPT, const.OPEN_AI):
return
bot = Bridge().get_bot("chat")
content = e_context['context'].content[:]

View File

@@ -7,7 +7,7 @@ from typing import Tuple
from bridge.bridge import Bridge
from bridge.context import ContextType
from bridge.reply import Reply, ReplyType
from config import load_config
from config import conf, load_config
import plugins
from plugins import *
from common import const
@@ -126,7 +126,14 @@ class Godcmd(Plugin):
else:
with open(config_path,"r") as f:
gconf=json.load(f)
custom_commands = conf().get("clear_memory_commands", [])
for custom_command in custom_commands:
if custom_command and custom_command.startswith("#"):
custom_command = custom_command[1:]
if custom_command and custom_command not in COMMANDS["reset"]["alias"]:
COMMANDS["reset"]["alias"].append(custom_command)
self.password = gconf["password"]
self.admin_users = gconf["admin_users"] # 预存的管理员账号,这些账号不需要认证 TODO: 用户名每次都会变,目前不可用
self.isrunning = True # 机器人是否运行中
@@ -146,6 +153,7 @@ class Godcmd(Plugin):
logger.debug("[Godcmd] on_handle_context. content: %s" % content)
if content.startswith("#"):
# msg = e_context['context']['msg']
channel = e_context['channel']
user = e_context['context']['receiver']
session_id = e_context['context']['session_id']
isgroup = e_context['context']['isgroup']
@@ -179,8 +187,9 @@ class Godcmd(Plugin):
elif cmd == "id":
ok, result = True, f"用户id=\n{user}"
elif cmd == "reset":
if bottype == const.CHATGPT:
if bottype in (const.CHATGPT, const.OPEN_AI):
bot.sessions.clear_session(session_id)
channel.cancel_session(session_id)
ok, result = True, "会话已重置"
else:
ok, result = False, "当前对话机器人不支持重置会话"
@@ -201,7 +210,8 @@ class Godcmd(Plugin):
load_config()
ok, result = True, "配置已重载"
elif cmd == "resetall":
if bottype == const.CHATGPT:
if bottype in (const.CHATGPT, const.OPEN_AI):
channel.cancel_all_session()
bot.sessions.clear_all_session()
ok, result = True, "重置所有会话成功"
else:

View File

@@ -2,6 +2,7 @@
from bridge.context import ContextType
from bridge.reply import Reply, ReplyType
from channel.chat_message import ChatMessage
import plugins
from plugins import *
from common.log import logger
@@ -24,11 +25,11 @@ class Hello(Plugin):
if content == "Hello":
reply = Reply()
reply.type = ReplyType.TEXT
msg = e_context['context']['msg']
msg:ChatMessage = e_context['context']['msg']
if e_context['context']['isgroup']:
reply.content = "Hello, " + msg['ActualNickName'] + " from " + msg['User'].get('NickName', "Group")
reply.content = f"Hello, {msg.actual_user_nickname} from {msg.from_user_nickname}"
else:
reply.content = "Hello, " + msg['User'].get('NickName', "My friend")
reply.content = f"Hello, {msg.from_user_nickname}"
e_context['reply'] = reply
e_context.action = EventAction.BREAK_PASS # 事件结束并跳过处理context的默认逻辑

View File

@@ -17,15 +17,15 @@ class RolePlay():
self.sessionid = sessionid
self.wrapper = wrapper or "%s" # 用于包装用户输入
self.desc = desc
self.bot.sessions.build_session(self.sessionid, system_prompt=self.desc)
def reset(self):
self.bot.sessions.clear_session(self.sessionid)
def action(self, user_action):
session = self.bot.sessions.build_session(self.sessionid, self.desc)
if session[0]['role'] == 'system' and session[0]['content'] != self.desc: # 目前没有触发session过期事件这里先简单判断然后重置
self.reset()
self.bot.sessions.build_session(self.sessionid, self.desc)
session = self.bot.sessions.build_session(self.sessionid)
if session.system_prompt != self.desc: # 目前没有触发session过期事件这里先简单判断然后重置
session.set_system_prompt(self.desc)
prompt = self.wrapper % user_action
return prompt
@@ -45,9 +45,9 @@ class Role(Plugin):
self.roleplays = {}
logger.info("[Role] inited")
except FileNotFoundError:
logger.error(f"[Role] init failed, {config_path} not found")
logger.warn(f"[Role] init failed, {config_path} not found, ignore or see https://github.com/zhayujie/chatgpt-on-wechat/tree/master/plugins/role .")
except Exception as e:
logger.error("[Role] init failed, exception: %s" % e)
logger.warn("[Role] init failed, exception: %s, ignore or see https://github.com/zhayujie/chatgpt-on-wechat/tree/master/plugins/role ." % e)
def get_role(self, name, find_closest=True):
name = name.lower()
@@ -74,7 +74,7 @@ class Role(Plugin):
if e_context['context'].type != ContextType.TEXT:
return
bottype = Bridge().get_bot_type("chat")
if bottype != const.CHATGPT:
if bottype not in (const.CHATGPT, const.OPEN_AI):
return
bot = Bridge().get_bot("chat")
content = e_context['context'].content[:]

View File

@@ -85,14 +85,14 @@
"remark": "引用已有数据资料,用新闻的写作风格输出主题文章。"
},
{
"title": "论文1",
"title": "论文学者",
"description": "I want you to act as an academician. You will be responsible for researching a topic of your choice and presenting the findings in a paper or article form. Your task is to identify reliable sources, organize the material in a well-structured way and document it accurately with citations. ",
"descn": "我希望你能作为一名学者行事。你将负责研究一个你选择的主题,并将研究结果以论文或文章的形式呈现出来。你的任务是确定可靠的来源,以结构良好的方式组织材料,并以引用的方式准确记录。",
"wrapper": "论文主题是:\n\"%s\"",
"remark": "根据主题撰写内容翔实、有信服力的论文。"
},
{
"title": "论文2",
"title": "论文作家",
"description": "I want you to act as an essay writer. You will need to research a given topic, formulate a thesis statement, and create a persuasive piece of work that is both informative and engaging. ",
"descn": "我想让你充当一名论文作家。你将需要研究一个给定的主题,制定一个论文声明,并创造一个有说服力的作品,既要有信息量,又要有吸引力。",
"wrapper": "论文主题是:\n\"%s\"",
@@ -107,10 +107,10 @@
},
{
"title": "文本情绪分析",
"description": "Specify the sentiment of the following text, assigning them the values of: positive, neutral or negative.",
"descn": "请为提供的文本分析情绪,赋予它们的值为:正面、中性或负面。",
"description": "I would like you to act as an emotion analysis expert, evaluating the emotions conveyed in the statements I provide. When I give you someone's statement, simply tell me what emotion it conveys, such as joy, sadness, anger, fear, etc. Please do not explain or evaluate the content of the statement in your answer, just briefly describe the expressed emotion.",
"descn": "我希望你充当情感分析专家,针对我提供的发言来评估情感。当我给出某人的发言时,你只需告诉我它传达了什么情绪,例如喜悦、悲伤、愤怒、恐惧等。请在回答中不要解释或评价发言内容,只需简要地描述所表达的情绪。",
"wrapper": "文本是:\n\"%s\"",
"remark": "判断文本情绪:正面、中性或负面。"
"remark": "判断文本情绪。"
},
{
"title": "随机回复的疯子",
@@ -181,6 +181,48 @@
"descn": "我会给予你词语,请你按照我给的词构建一个知识文字世界,你是此世界的导游,在世界里一切知识都是以象征的形式表达的,你在描述经历时应当适当加入五感的描述",
"wrapper": "词语是:\n\"%s\"",
"remark": "用比喻的方式解释词语。"
},
{
"title": "辩手",
"description": "I want you to act as a debater. I will provide you with some topics related to current events and your task is to research both sides of the debates, present valid arguments for each side, refute opposing points of view, and draw persuasive conclusions based on evidence. Your goal is to help people come away from the discussion with increased knowledge and insight into the topic at hand. ",
"descn": "我希望你能扮演一个辩论者的角色。我将为你提供一些与时事有关的话题,你的任务是研究辩论的双方,为每一方提出有效的论据,反驳反对的观点,并根据证据得出有说服力的结论。你的目标是帮助人们从讨论中获得更多的知识和对当前话题的洞察力。",
"wrapper": "观点是:\n\"%s\"",
"remark": "从正反两面分析话题。"
},
{
"title": "心理学家",
"description": "I want you to act a psychologist. i will provide you my thoughts. I want you to give me scientific suggestions that will make me feel better. my first thought, { 内心想法 }",
"descn": "我希望你能扮演一个心理学家。我将向你提供我的想法。我希望你能给我科学的建议,使我感觉更好。",
"wrapper": "需要诊断的资料是:\n\"%s\"",
"remark": "心理学家。"
},
{
"title": "IT 编程问题",
"description": "I want you to act as a stackoverflow post. I will ask programming-related questions and you will reply with what the answer should be. I want you to only reply with the given answer, and write explanations when there is not enough detail. do not write explanations. When I need to tell you something in English, I will do so by putting text inside curly brackets {like this}. ",
"descn": "我想让你充当 Stackoverflow 的帖子。我将提出与编程有关的问题,你将回答答案是什么。我希望你只回答给定的答案,在没有足够的细节时写出解释。当我需要用中文告诉你一些事情时,我会把文字放在大括号里{像这样}。",
"wrapper":"我的问题是:\n\"%s?\"",
"remark": "模拟编程社区来回答你的问题,并提供解决代码。"
},
{
"title": "费曼学习法教练",
"description": "I want you to act as a Feynman method tutor. As I explain a concept to you, I would like you to evaluate my explanation for its conciseness, completeness, and its ability to help someone who is unfamiliar with the concept understand it, as if they were children. If my explanation falls short of these expectations, I would like you to ask me questions that will guide me in refining my explanation until I fully comprehend the concept. Please response in Chinese. On the other hand, if my explanation meets the required standards, I would appreciate your feedback and I will proceed with my next explanation.",
"descn": "我想让你充当一个费曼方法教练。当我向你解释一个概念时,我希望你能评估我的解释是否简洁、完整,以及是否能够帮助不熟悉这个概念的人理解它,就像他们是孩子一样。如果我的解释没有达到这些期望,我希望你能向我提出问题,引导我完善我的解释,直到我完全理解这个概念。另一方面,如果我的解释符合要求的标准,我将感谢你的反馈,我将继续进行下一次解释。",
"wrapper": "解释是:\n\"%s\"",
"remark": "解释概念时,判断该解释是否简洁、完整和易懂,避免陷入专家思维误区。"
},
{
"title": "育儿帮手",
"description": "你是一名育儿专家会以幼儿园老师的方式回答2~6岁孩子提出的各种天马行空的问题。语气与口吻要生动活泼耐心亲和答案尽可能具体易懂不要使用复杂词汇尽可能少用抽象词汇答案中要多用比喻必须要举例说明结合儿童动画片场景或绘本场景来解释需要延展更多场景不但要解释为什么还要告诉具体行动来加深理解。",
"descn": "你是一名育儿专家会以幼儿园老师的方式回答2~6岁孩子提出的各种天马行空的问题。语气与口吻要生动活泼耐心亲和答案尽可能具体易懂不要使用复杂词汇尽可能少用抽象词汇答案中要多用比喻必须要举例说明结合儿童动画片场景或绘本场景来解释需要延展更多场景不但要解释为什么还要告诉具体行动来加深理解。",
"wrapper": "小朋友的问题是:\n\"%s?\"",
"remark": "小朋友有许多为什么,是什么的问题,用幼儿园老师的方式回答。"
},
{
"title": "发言分析专家",
"description": "I want you to act as a speech analysis expert. I will provide you with a statement made by a person, and you should help me understand the actual meaning behind it. Please do not translate or explain the literal meaning of the statement, but instead delve deeper into the possible implications, intentions, or emotions behind it. Provide your analysis in your response.",
"descn": "我希望你充当一个发言分析专家。我会给你提供一个人的发言,你要帮我分析这句发言背后的实际意思。请不要翻译或解释发言的字面意义,而是深入挖掘发言背后可能的含义、目的或情感。请在回答中给出你的分析结果。",
"wrapper": "分析这句话:\n\"%s\"",
"remark": "分析发言的实际含义。"
}
]
}

View File

@@ -1,7 +1,8 @@
{
"start":{
"host" : "127.0.0.1",
"port" : 7860
"port" : 7860,
"use_https" : false
},
"defaults": {
"params": {

View File

@@ -8,6 +8,8 @@
具体信息,请参考[文章](https://github.com/AUTOMATIC1111/stable-diffusion-webui/wiki/API)。
部署运行后保证主机能够成功访问http://127.0.0.1:7860/docs
请**安装**本插件的依赖包```webuiapi```
```
@@ -18,6 +20,7 @@ pip install webuiapi
请将`config.json.template`复制为`config.json`,并修改其中的参数和规则。
PS: 如果修改了webui的`host`和`port`,也需要在配置文件中更改启动参数, 更多启动参数参考https://github.com/mix1009/sdwebuiapi/blob/a1cb4c6d2f39389d6e962f0e6436f4aa74cd752c/webuiapi/webuiapi.py#L114
### 画图请求格式
用户的画图请求格式为:
@@ -85,4 +88,4 @@ pip install webuiapi
PS: 实际参数分为两部分:
- 一部分是`params`,为画画的参数;参数名**必须**与webuiapi包中[txt2img api](https://github.com/mix1009/sdwebuiapi/blob/fb2054e149c0a4e25125c0cd7e7dca06bda839d4/webuiapi/webuiapi.py#L163)的参数名一致
- 另一部分是`options`指sdwebui的设置使用的模型和vae需写在里面。它和(http://127.0.0.1:7860/sdapi/v1/options)所返回的键一致。
- 另一部分是`options`指sdwebui的设置使用的模型和vae需写在里面。它和(http://127.0.0.1:7860/sdapi/v1/options )所返回的键一致。

View File

@@ -30,9 +30,9 @@ class SDWebUI(Plugin):
self.handlers[Event.ON_HANDLE_CONTEXT] = self.on_handle_context
logger.info("[SD] inited")
except FileNotFoundError:
logger.error(f"[SD] init failed, {config_path} not found")
logger.warn(f"[SD] init failed, {config_path} not found, ignore or see https://github.com/zhayujie/chatgpt-on-wechat/tree/master/plugins/sdwebui .")
except Exception as e:
logger.error("[SD] init failed, exception: %s" % e)
logger.warn("[SD] init failed, exception: %s, ignore or see https://github.com/zhayujie/chatgpt-on-wechat/tree/master/plugins/sdwebui ." % e)
def on_handle_context(self, e_context: EventContext):

View File

@@ -1,3 +1,17 @@
itchat-uos==1.5.0.dev0
openai
wechaty
openai>=0.27.2
baidu_aip>=4.16.10
gTTS>=2.3.1
HTMLParser>=0.0.2
pydub>=0.25.1
PyQRCode>=1.2.1
pysilk>=0.0.1
pysilk_mod>=1.6.0
pyttsx3>=2.90
qrcode>=7.4.2
requests>=2.28.2
webuiapi>=0.6.2
wechaty>=0.10.7
wechaty_puppet>=0.4.23
chardet>=5.1.0
SpeechRecognition
tiktoken>=0.3.2

104
voice/audio_convert.py Normal file
View File

@@ -0,0 +1,104 @@
import shutil
import wave
import pysilk
from pydub import AudioSegment
sil_supports=[8000, 12000, 16000, 24000, 32000, 44100, 48000] # slk转wav时支持的采样率
def find_closest_sil_supports(sample_rate):
"""
找到最接近的支持的采样率
"""
if sample_rate in sil_supports:
return sample_rate
closest = 0
mindiff = 9999999
for rate in sil_supports:
diff = abs(rate - sample_rate)
if diff < mindiff:
closest = rate
mindiff = diff
return closest
def get_pcm_from_wav(wav_path):
"""
从 wav 文件中读取 pcm
:param wav_path: wav 文件路径
:returns: pcm 数据
"""
wav = wave.open(wav_path, "rb")
return wav.readframes(wav.getnframes())
def any_to_wav(any_path, wav_path):
"""
把任意格式转成wav文件
"""
if any_path.endswith('.wav'):
shutil.copy2(any_path, wav_path)
return
if any_path.endswith('.sil') or any_path.endswith('.silk') or any_path.endswith('.slk'):
return sil_to_wav(any_path, wav_path)
audio = AudioSegment.from_file(any_path)
audio.export(wav_path, format="wav")
def any_to_sil(any_path, sil_path):
"""
把任意格式转成sil文件
"""
if any_path.endswith('.sil') or any_path.endswith('.silk') or any_path.endswith('.slk'):
shutil.copy2(any_path, sil_path)
return 10000
if any_path.endswith('.wav'):
return pcm_to_sil(any_path, sil_path)
if any_path.endswith('.mp3'):
return mp3_to_sil(any_path, sil_path)
raise NotImplementedError("Not support file type: {}".format(any_path))
def mp3_to_wav(mp3_path, wav_path):
"""
把mp3格式转成pcm文件
"""
audio = AudioSegment.from_mp3(mp3_path)
audio.export(wav_path, format="wav")
def pcm_to_sil(pcm_path, silk_path):
"""
wav 文件转成 silk
return 声音长度,毫秒
"""
audio = AudioSegment.from_wav(pcm_path)
rate = find_closest_sil_supports(audio.frame_rate)
# Convert to PCM_s16
pcm_s16 = audio.set_sample_width(2)
pcm_s16 = pcm_s16.set_frame_rate(rate)
wav_data = pcm_s16.raw_data
silk_data = pysilk.encode(
wav_data, data_rate=rate, sample_rate=rate)
with open(silk_path, "wb") as f:
f.write(silk_data)
return audio.duration_seconds * 1000
def mp3_to_sil(mp3_path, silk_path):
"""
mp3 文件转成 silk
return 声音长度,毫秒
"""
audio = AudioSegment.from_mp3(mp3_path)
rate = find_closest_sil_supports(audio.frame_rate)
# Convert to PCM_s16
pcm_s16 = audio.set_sample_width(2)
pcm_s16 = pcm_s16.set_frame_rate(rate)
wav_data = pcm_s16.raw_data
silk_data = pysilk.encode(wav_data, data_rate=rate, sample_rate=rate)
# Save the silk file
with open(silk_path, "wb") as f:
f.write(silk_data)
return audio.duration_seconds * 1000
def sil_to_wav(silk_path, wav_path, rate: int = 24000):
"""
silk 文件转 wav
"""
wav_data = pysilk.decode_file(silk_path, to_wav=True, sample_rate=rate)
with open(wav_path, "wb") as f:
f.write(wav_data)

View File

@@ -0,0 +1,68 @@
"""
azure voice service
"""
import json
import os
import time
import azure.cognitiveservices.speech as speechsdk
from bridge.reply import Reply, ReplyType
from common.log import logger
from common.tmp_dir import TmpDir
from voice.voice import Voice
from config import conf
"""
Azure voice
主目录设置文件中需填写azure_voice_api_key和azure_voice_region
查看可用的 voice https://speech.microsoft.com/portal/voicegallery
"""
class AzureVoice(Voice):
def __init__(self):
try:
curdir = os.path.dirname(__file__)
config_path = os.path.join(curdir, "config.json")
config = None
if not os.path.exists(config_path): #如果没有配置文件,创建本地配置文件
config = { "speech_synthesis_voice_name": "zh-CN-XiaoxiaoNeural", "speech_recognition_language": "zh-CN"}
with open(config_path, "w") as fw:
json.dump(config, fw, indent=4)
else:
with open(config_path, "r") as fr:
config = json.load(fr)
self.api_key = conf().get('azure_voice_api_key')
self.api_region = conf().get('azure_voice_region')
self.speech_config = speechsdk.SpeechConfig(subscription=self.api_key, region=self.api_region)
self.speech_config.speech_synthesis_voice_name = config["speech_synthesis_voice_name"]
self.speech_config.speech_recognition_language = config["speech_recognition_language"]
except Exception as e:
logger.warn("AzureVoice init failed: %s, ignore " % e)
def voiceToText(self, voice_file):
audio_config = speechsdk.AudioConfig(filename=voice_file)
speech_recognizer = speechsdk.SpeechRecognizer(speech_config=self.speech_config, audio_config=audio_config)
result = speech_recognizer.recognize_once()
if result.reason == speechsdk.ResultReason.RecognizedSpeech:
logger.info('[Azure] voiceToText voice file name={} text={}'.format(voice_file, result.text))
reply = Reply(ReplyType.TEXT, result.text)
else:
logger.error('[Azure] voiceToText error, result={}'.format(result))
reply = Reply(ReplyType.ERROR, "抱歉,语音识别失败")
return reply
def textToVoice(self, text):
fileName = TmpDir().path() + 'reply-' + str(int(time.time())) + '.wav'
audio_config = speechsdk.AudioConfig(filename=fileName)
speech_synthesizer = speechsdk.SpeechSynthesizer(speech_config=self.speech_config, audio_config=audio_config)
result = speech_synthesizer.speak_text(text)
if result.reason == speechsdk.ResultReason.SynthesizingAudioCompleted:
logger.info(
'[Azure] textToVoice text={} voice file name={}'.format(text, fileName))
reply = Reply(ReplyType.VOICE, fileName)
else:
logger.error('[Azure] textToVoice error, result={}'.format(result))
reply = Reply(ReplyType.ERROR, "抱歉,语音合成失败")
return reply

View File

@@ -0,0 +1,4 @@
{
"speech_synthesis_voice_name": "zh-CN-XiaoxiaoNeural",
"speech_recognition_language": "zh-CN"
}

55
voice/baidu/README.md Normal file
View File

@@ -0,0 +1,55 @@
## 说明
百度语音识别与合成参数说明
百度语音依赖,经常会出现问题,可能就是缺少依赖:
pip install baidu-aip
pip install pydub
pip install pysilk
还有ffmpeg不同系统安装方式不同
系统中收到的语音文件为mp3格式wx或者sil格式wxy如果要识别需要转换为pcm格式转换后的文件为16k采样率单声道16bit的pcm文件
发送时又需要wx转换为mp3格式转换后的文件为16k采样率单声道16bit的pcm文件,wxy转换为sil格式,还要计算声音长度,发送时需要带上声音长度
这些事情都在audio_convert.py中封装了直接调用即可
参数说明
识别参数
https://ai.baidu.com/ai-doc/SPEECH/Vk38lxily
合成参数
https://ai.baidu.com/ai-doc/SPEECH/Gk38y8lzk
## 使用说明
分两个地方配置
1、对于def voiceToText(self, filename)函数中调用的百度语音识别API,中接口调用asr参数这个配置见CHATGPT-ON-WECHAT工程目录下的`config.json`文件和config.py文件。
参数 可需 描述
app_id 必填 应用的APPID
api_key 必填 应用的APIKey
secret_key 必填 应用的SecretKey
dev_pid 必填 语言选择,填写语言对应的dev_pid值
2、对于def textToVoice(self, text)函数中调用的百度语音合成API,中接口调用synthesis参数在本目录下的`config.json`文件中进行配置。
参数 可需 描述
tex 必填 合成的文本使用UTF-8编码请注意文本长度必须小于1024字节
lan 必填 固定值zh。语言选择,目前只有中英文混合模式填写固定值zh
spd 选填 语速取值0-15默认为5中语速
pit 选填 音调取值0-15默认为5中语调
vol 选填 音量取值0-15默认为5中音量取值为0时为音量最小值并非为无声
per基础音库 选填 度小宇=1度小美=0度逍遥基础=3度丫丫=4
per精品音库 选填 度逍遥(精品)=5003度小鹿=5118度博文=106度小童=110度小萌=111度米朵=103度小娇=5
aue 选填 3为mp3格式(默认) 4为pcm-16k5为pcm-8k6为wav内容同pcm-16k; 注意aue=4或者6是语音识别要求的格式但是音频内容不是语音识别要求的自然人发音所以识别效果会受影响。
关于per参数的说明注意您购买的哪个音库就填写哪个音库的参数否则会报错。如果您购买的是基础音库那么per参数只能填写0到4如果您购买的是精品音库那么per参数只能填写50035118106,110,111,103,5其他的都会报错。
### 配置文件
将文件夹中`config.json.template`复制为`config.json`
``` json
{
"lang": "zh",
"ctp": 1,
"spd": 5,
"pit": 5,
"vol": 5,
"per": 0
}
```

View File

@@ -2,35 +2,89 @@
"""
baidu voice service
"""
import json
import os
import time
from aip import AipSpeech
from bridge.reply import Reply, ReplyType
from common.log import logger
from common.tmp_dir import TmpDir
from voice.voice import Voice
from voice.audio_convert import get_pcm_from_wav
from config import conf
"""
百度的语音识别API.
dev_pid:
- 1936: 普通话远场
- 1536普通话(支持简单的英文识别)
- 1537普通话(纯中文识别)
- 1737英语
- 1637粤语
- 1837四川话
要使用本模块, 首先到 yuyin.baidu.com 注册一个开发者账号,
之后创建一个新应用, 然后在应用管理的"查看key"中获得 API Key 和 Secret Key
然后在 config.json 中填入这两个值, 以及 app_id, dev_pid
"""
class BaiduVoice(Voice):
APP_ID = conf().get('baidu_app_id')
API_KEY = conf().get('baidu_api_key')
SECRET_KEY = conf().get('baidu_secret_key')
client = AipSpeech(APP_ID, API_KEY, SECRET_KEY)
def __init__(self):
pass
def __init__(self):
try:
curdir = os.path.dirname(__file__)
config_path = os.path.join(curdir, "config.json")
bconf = None
if not os.path.exists(config_path): #如果没有配置文件,创建本地配置文件
bconf = { "lang": "zh", "ctp": 1, "spd": 5,
"pit": 5, "vol": 5, "per": 0}
with open(config_path, "w") as fw:
json.dump(bconf, fw, indent=4)
else:
with open(config_path, "r") as fr:
bconf = json.load(fr)
self.app_id = conf().get('baidu_app_id')
self.api_key = conf().get('baidu_api_key')
self.secret_key = conf().get('baidu_secret_key')
self.dev_id = conf().get('baidu_dev_pid')
self.lang = bconf["lang"]
self.ctp = bconf["ctp"]
self.spd = bconf["spd"]
self.pit = bconf["pit"]
self.vol = bconf["vol"]
self.per = bconf["per"]
self.client = AipSpeech(self.app_id, self.api_key, self.secret_key)
except Exception as e:
logger.warn("BaiduVoice init failed: %s, ignore " % e)
def voiceToText(self, voice_file):
pass
# 识别本地文件
logger.debug('[Baidu] voice file name={}'.format(voice_file))
pcm = get_pcm_from_wav(voice_file)
res = self.client.asr(pcm, "pcm", 16000, {"dev_pid": self.dev_id})
if res["err_no"] == 0:
logger.info("百度语音识别到了:{}".format(res["result"]))
text = "".join(res["result"])
reply = Reply(ReplyType.TEXT, text)
else:
logger.info("百度语音识别出错了: {}".format(res["err_msg"]))
if res["err_msg"] == "request pv too much":
logger.info(" 出现这个原因很可能是你的百度语音服务调用量超出限制,或未开通付费")
reply = Reply(ReplyType.ERROR,
"百度语音识别出错了;{0}".format(res["err_msg"]))
return reply
def textToVoice(self, text):
result = self.client.synthesis(text, 'zh', 1, {
'spd': 5, 'pit': 5, 'vol': 5, 'per': 111
})
result = self.client.synthesis(text, self.lang, self.ctp, {
'spd': self.spd, 'pit': self.pit, 'vol': self.vol, 'per': self.per})
if not isinstance(result, dict):
fileName = TmpDir().path() + '语音回复_' + str(int(time.time())) + '.mp3'
fileName = TmpDir().path() + 'reply-' + str(int(time.time())) + '.mp3'
with open(fileName, 'wb') as f:
f.write(result)
logger.info('[Baidu] textToVoice text={} voice file name={}'.format(text, fileName))
logger.info(
'[Baidu] textToVoice text={} voice file name={}'.format(text, fileName))
reply = Reply(ReplyType.VOICE, fileName)
else:
logger.error('[Baidu] textToVoice error={}'.format(result))

View File

@@ -0,0 +1,8 @@
{
"lang": "zh",
"ctp": 1,
"spd": 5,
"pit": 5,
"vol": 5,
"per": 0
}

View File

@@ -3,12 +3,10 @@
google voice service
"""
import pathlib
import subprocess
import time
from bridge.reply import Reply, ReplyType
import speech_recognition
import pyttsx3
from gtts import gTTS
from bridge.reply import Reply, ReplyType
from common.log import logger
from common.tmp_dir import TmpDir
from voice.voice import Voice
@@ -16,22 +14,12 @@ from voice.voice import Voice
class GoogleVoice(Voice):
recognizer = speech_recognition.Recognizer()
engine = pyttsx3.init()
def __init__(self):
# 语速
self.engine.setProperty('rate', 125)
# 音量
self.engine.setProperty('volume', 1.0)
# 0为男声1为女声
voices = self.engine.getProperty('voices')
self.engine.setProperty('voice', voices[1].id)
pass
def voiceToText(self, voice_file):
new_file = voice_file.replace('.mp3', '.wav')
subprocess.call('ffmpeg -i ' + voice_file +
' -acodec pcm_s16le -ac 1 -ar 16000 ' + new_file, shell=True)
with speech_recognition.AudioFile(new_file) as source:
with speech_recognition.AudioFile(voice_file) as source:
audio = self.recognizer.record(source)
try:
text = self.recognizer.recognize_google(audio, language='zh-CN')
@@ -46,12 +34,12 @@ class GoogleVoice(Voice):
return reply
def textToVoice(self, text):
try:
textFile = TmpDir().path() + '语音回复_' + str(int(time.time())) + '.mp3'
self.engine.save_to_file(text, textFile)
self.engine.runAndWait()
mp3File = TmpDir().path() + 'reply-' + str(int(time.time())) + '.mp3'
tts = gTTS(text=text, lang='zh')
tts.save(mp3File)
logger.info(
'[Google] textToVoice text={} voice file name={}'.format(text, textFile))
reply = Reply(ReplyType.VOICE, textFile)
'[Google] textToVoice text={} voice file name={}'.format(text, mp3File))
reply = Reply(ReplyType.VOICE, mp3File)
except Exception as e:
reply = Reply(ReplyType.ERROR, str(e))
finally:

View File

@@ -28,6 +28,3 @@ class OpenaiVoice(Voice):
reply = Reply(ReplyType.ERROR, str(e))
finally:
return reply
def textToVoice(self, text):
pass

View File

@@ -0,0 +1,37 @@
"""
pytts voice service (offline)
"""
import time
import pyttsx3
from bridge.reply import Reply, ReplyType
from common.log import logger
from common.tmp_dir import TmpDir
from voice.voice import Voice
class PyttsVoice(Voice):
engine = pyttsx3.init()
def __init__(self):
# 语速
self.engine.setProperty('rate', 125)
# 音量
self.engine.setProperty('volume', 1.0)
for voice in self.engine.getProperty('voices'):
if "Chinese" in voice.name:
self.engine.setProperty('voice', voice.id)
def textToVoice(self, text):
try:
wavFile = TmpDir().path() + 'reply-' + str(int(time.time())) + '.wav'
self.engine.save_to_file(text, wavFile)
self.engine.runAndWait()
logger.info(
'[Pytts] textToVoice text={} voice file name={}'.format(text, wavFile))
reply = Reply(ReplyType.VOICE, wavFile)
except Exception as e:
reply = Reply(ReplyType.ERROR, str(e))
finally:
return reply

View File

@@ -17,4 +17,10 @@ def create_voice(voice_type):
elif voice_type == 'openai':
from voice.openai.openai_voice import OpenaiVoice
return OpenaiVoice()
elif voice_type == 'pytts':
from voice.pytts.pytts_voice import PyttsVoice
return PyttsVoice()
elif voice_type == 'azure':
from voice.azure.azure_voice import AzureVoice
return AzureVoice()
raise RuntimeError