diff --git a/README.md b/README.md index c79c54eb..153e9c5c 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ **应用:** - [x] [终端](https://github.com/zhayujie/bot-on-anything#1%E5%91%BD%E4%BB%A4%E8%A1%8C%E7%BB%88%E7%AB%AF) - - [ ] Web + - [x] [Web](https://github.com/zhayujie/bot-on-anything#9web) - [x] [个人微信](https://github.com/zhayujie/bot-on-anything#2%E4%B8%AA%E4%BA%BA%E5%BE%AE%E4%BF%A1) - [x] [订阅号](https://github.com/zhayujie/bot-on-anything#3%E4%B8%AA%E4%BA%BA%E8%AE%A2%E9%98%85%E5%8F%B7) - [x] [服务号](https://github.com/zhayujie/bot-on-anything#4%E4%BC%81%E4%B8%9A%E6%9C%8D%E5%8A%A1%E5%8F%B7) @@ -393,3 +393,35 @@ http:/你的固定公网ip或者域名:端口/slack/events ``` https://slack.dev/bolt-python/tutorial/getting-started ``` + +### 9.Web +#### http +**需要:** 服务器 + + +**依赖** + +```bash +pip3 install PyJWT flask +``` + +**配置** + +```bash +"channel": { + "type": "http", + "http": { + "http_auth_secret_key": "6d25a684-9558-11e9-aa94-efccd7a0659b",//jwt认证秘钥 + "http_auth_password": "6.67428e-11",//认证密码,仅仅只是自用,最初步的防御别人扫描端口后DDOS浪费tokens + "port": "80"//端口 + } + } +``` + + + +URL,如果端口是 80 ,可不填 + +``` +http:/你的固定公网ip或者域名:端口/ +``` diff --git a/channel/channel_factory.py b/channel/channel_factory.py index eb38a048..186d4458 100644 --- a/channel/channel_factory.py +++ b/channel/channel_factory.py @@ -41,5 +41,9 @@ def create_channel(channel_type): from channel.slack.slack_channel import SlackChannel return SlackChannel() + elif channel_type == const.HTTP: + from channel.http.http_channel import HttpChannel + return HttpChannel() + else: raise RuntimeError diff --git a/channel/http/auth.py b/channel/http/auth.py new file mode 100644 index 00000000..67b2ec40 --- /dev/null +++ b/channel/http/auth.py @@ -0,0 +1,107 @@ +# encoding:utf-8 + +import jwt +import datetime +import time +from flask import jsonify, request +from common import const +from config import channel_conf + + +class Auth(): + def __init__(self, login): + # argument 'privilegeRequired' is to set up your method's privilege + # name + self.login = login + super(Auth, self).__init__() + + @staticmethod + def encode_auth_token(user_id, login_time): + """ + 生成认证Token + :param user_id: int + :param login_time: datetime + :return: string + """ + try: + payload = { + 'iss': 'ken', # 签名 + 'exp': datetime.datetime.utcnow() + datetime.timedelta(days=0, hours=10), # 过期时间 + 'iat': datetime.datetime.utcnow(), # 开始时间 + 'data': { + 'id': user_id, + 'login_time': login_time + } + } + return jwt.encode( + payload, + channel_conf(const.HTTP).get('http_auth_secret_key'), + algorithm='HS256' + ) # 加密生成字符串 + except Exception as e: + return e + + @staticmethod + def decode_auth_token(auth_token): + """ + 验证Token + :param auth_token: + :return: integer|string + """ + try: + # 取消过期时间验证 + payload = jwt.decode(auth_token, channel_conf(const.HTTP).get( + 'http_auth_secret_key'), algorithms='HS256') # options={'verify_exp': False} 加上后不验证token过期时间 + if ('data' in payload and 'id' in payload['data']): + return payload + else: + raise jwt.InvalidTokenError + except jwt.ExpiredSignatureError: + return 'Token过期' + except jwt.InvalidTokenError: + return '无效Token' + + +def authenticate(password): + """ + 用户登录,登录成功返回token + :param password: + :return: json + """ + authPassword = channel_conf(const.HTTP).get('http_auth_password') + if (authPassword != password): + return False + else: + login_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) + token = Auth.encode_auth_token(password, login_time) + return token + + +def identify(request): + """ + 用户鉴权 + :return: list + """ + try: + if (request is None): + return False + authorization = request.cookies.get('Authorization') + if (authorization): + payload = Auth.decode_auth_token(authorization) + if not isinstance(payload, str): + authPassword = channel_conf( + const.HTTP).get('http_auth_password') + password = payload['data']['id'] + if (password != authPassword): + return False + else: + return True + return False + + except jwt.ExpiredSignatureError: + #result = 'Token已更改,请重新登录获取' + return False + + except jwt.InvalidTokenError: + #result = '没有提供认证token' + return False diff --git a/channel/http/http_channel.py b/channel/http/http_channel.py new file mode 100644 index 00000000..edb5a842 --- /dev/null +++ b/channel/http/http_channel.py @@ -0,0 +1,66 @@ +# encoding:utf-8 + +import json +from channel.http import auth +from flask import Flask, request, render_template, make_response +from datetime import timedelta +from common import const +from config import channel_conf +from channel.channel import Channel +http_app = Flask(__name__,) +# 自动重载模板文件 +http_app.jinja_env.auto_reload = True +http_app.config['TEMPLATES_AUTO_RELOAD'] = True + +# 设置静态文件缓存过期时间 +http_app.config['SEND_FILE_MAX_AGE_DEFAULT'] = timedelta(seconds=1) + + +@http_app.route("/chat", methods=['POST']) +def chat(): + if (auth.identify(request) == False): + return + data = json.loads(request.data) + if data: + msg = data['msg'] + if not msg: + return + reply_text = HttpChannel().handle(data=data) + return {'result': reply_text} + + +@http_app.route("/", methods=['GET']) +def index(): + if (auth.identify(request) == False): + return login() + return render_template('index.html') + + +@http_app.route("/login", methods=['POST', 'GET']) +def login(): + response = make_response("",301) + response.headers.add_header('content-type','text/plain') + response.headers.add_header('location','./') + if (auth.identify(request) == True): + return response + else: + if request.method == "POST": + token = auth.authenticate(request.form['password']) + if (token != False): + response.set_cookie(key='Authorization', value=token) + return response + else: + return render_template('login.html') + response.headers.set('location','./login?err=登录失败') + return response + +class HttpChannel(Channel): + def startup(self): + http_app.run(host='0.0.0.0', port=channel_conf(const.HTTP).get('port')) + + def handle(self, data): + context = dict() + id = data["id"] + context['from_user_id'] = str(id) + return super().build_reply_content(data["msg"], context) + diff --git a/channel/http/static/1.css b/channel/http/static/1.css new file mode 100644 index 00000000..8ed498b7 --- /dev/null +++ b/channel/http/static/1.css @@ -0,0 +1,329 @@ + +.typing_loader { + width: 6px; + height: 6px; + border-radius: 50%; + -webkit-animation: typing 1s linear infinite alternate; + -moz-animation: typing 1s linear infinite alternate; + -ms-animation: typing 1s linear infinite alternate; + animation: typing 1s linear infinite alternate; + position: relative; + left: -12px; + margin: 7px 15px 6px; +} +ol,pre { + background-color: #b1e3b1c4; + border: 1px solid #c285e3ab; + padding: 0.5rem 1.5rem 0.5rem; + color: black; + border-radius: 10px; +} +.to .typing_loader { + animation: typing-black 1s linear infinite alternate; +} + +@-webkit-keyframes typing { + 0% { + background-color: rgba(255,255,255, 1); + box-shadow: 12px 0px 0px 0px rgba(255,255,255,0.4), 24px 0px 0px 0px rgba(255,255,255,0.2); + } + + 50% { + background-color: rgba(255,255,255, 0.4); + box-shadow: 12px 0px 0px 0px rgba(255,255,255,1), 24px 0px 0px 0px rgba(255,255,255,0.4); + } + + 100% { + background-color: rgba(255,255,255, 0.2); + box-shadow: 12px 0px 0px 0px rgba(255,255,255,0.4), 24px 0px 0px 0px rgba(255,255,255,1); + } +} + +@-moz-keyframes typing { + 0% { + background-color: rgba(255,255,255, 1); + box-shadow: 12px 0px 0px 0px rgba(255,255,255,0.4), 24px 0px 0px 0px rgba(255,255,255,0.2); + } + + 50% { + background-color: rgba(255,255,255, 0.4); + box-shadow: 12px 0px 0px 0px rgba(255,255,255,1), 24px 0px 0px 0px rgba(255,255,255,0.4); + } + + 100% { + background-color: rgba(255,255,255, 0.2); + box-shadow: 12px 0px 0px 0px rgba(255,255,255,0.4), 24px 0px 0px 0px rgba(255,255,255,1); + } +} + +@keyframes typing-black { + 0% { + background-color: rgba(74, 74, 74, 1); + box-shadow: 12px 0px 0px 0px rgba(74, 74, 74, 0.4), 24px 0px 0px 0px rgba(74, 74, 74, 0.2); + } + + 50% { + background-color: rgba(74, 74, 74, 0.4); + box-shadow: 12px 0px 0px 0px rgba(74, 74, 74, 1), 24px 0px 0px 0px rgba(74, 74, 74,0.4); + } + + 100% { + background-color: rgba(74, 74, 74, 0.2); + box-shadow: 12px 0px 0px 0px rgba(74, 74, 74,0.4), 24px 0px 0px 0px rgba(74, 74, 74,1); + } +} + +@keyframes typing { + 0% { + background-color: rgba(255,255,255, 1); + box-shadow: 12px 0px 0px 0px rgba(255,255,255,0.4), 24px 0px 0px 0px rgba(255,255,255,0.2); + } + + 50% { + background-color: rgba(255,255,255, 0.4); + box-shadow: 12px 0px 0px 0px rgba(255,255,255,1), 24px 0px 0px 0px rgba(255,255,255,0.4); + } + + 100% { + background-color: rgba(255,255,255, 0.2); + box-shadow: 12px 0px 0px 0px rgba(255,255,255,0.4), 24px 0px 0px 0px rgba(255,255,255,1); + } +} + +.convFormDynamic { + text-align: center; + margin: 10px 10px; + padding: 0 !important; + position: relative; + border: 2px solid rgba(0, 40, 100, 0.12); +} + +.convFormDynamic textarea.userInputDynamic { + border: none; + padding: 7px 10px; + overflow-x: hidden!important; + outline: none; + font-size: 0.905rem; + float: left; + width: calc(100% - 70px); + line-height: 1.3em; + min-height: 1.7em; + max-height: 10rem; + display: block; + max-width: 89vw; + margin-right: -1vw; + resize: none; +} +.convFormDynamic textarea::-webkit-scrollbar{ + width: 2px; + background-color: lawngreen; +} +.convFormDynamic textarea::-webkit-scrollbar-thumb{ + -webkit-box-shadow: inset 0 0 6px rgba(0,0,0,.3); + background-color: dodgerblue; +} +.convFormDynamic input.userInputDynamic { + border: none; + padding: 7px 10px; + outline: none; + font-size: 0.905rem; + float: left; + width: calc(100% - 70px); + line-height: 1.3em; + min-height: 1.7em; + max-height: 10rem; + display: block; + max-width: 89vw; + margin-right: -1vw; +} + +div.conv-form-wrapper div#messages { + max-height: 71vh; + height: auto !important; + overflow-y: scroll; +} + +div.conv-form-wrapper div#messages:after { + content: ''; + display: table; + clear: both; +} + +div.conv-form-wrapper { + position: relative; +} + +div.conv-form-wrapper div.wrapper-messages { + position: relative; + height: 76vh; + max-height: 80vh; + overflow-y: scroll; +} + +div.conv-form-wrapper:before { + content: ''; + position: absolute; + width: 100%; + display: block; + height: 30px; + top: 0; + left: 0; + z-index: 2; + background: linear-gradient(#fff, transparent); +} + +@media (max-width: 767px) { + div.conv-form-wrapper div.wrapper-messages, div.conv-form-wrapper div#messages { + max-height: 71vh; + } +} + +div.conv-form-wrapper div.wrapper-messages::-webkit-scrollbar, div#feed ul::-webkit-scrollbar, div.conv-form-wrapper div.options::-webkit-scrollbar { + width: 0px; + height: 0px; + /* remove scrollbar space */ + background: transparent; + /* optional: just make scrollbar invisible */ +} + +input[type="text"].userInputDynamic.error { + color: #ac0000 !important; +} + +input[type="text"].userInputDynamic { + border-radius: 3px; + margin: 7px 10px; +} + +textarea.userInputDynamic.error { + color: #ac0000 !important; +} + +textarea.userInputDynamic { + border-radius: 3px; + margin: 7px 10px; +} + +div.conv-form-wrapper div#messages { + transition: bottom 0.15s, padding-bottom 0.15s; + position: absolute; + bottom: 0; + height: auto !important; + width: 100%; + padding-bottom: 20px; + /*max-height: 71vh;*/ +} + +div.conv-form-wrapper div.message { + animation: slideTop 0.15s ease; +} + +div.conv-form-wrapper div.message:after { + content: ''; + display: table; + clear: both; +} + +div.conv-form-wrapper div.message.ready { + animation: bounceIn 0.2s ease; + transform-origin: 0 0 0; +} + +div.conv-form-wrapper div#messages div.message { + border-radius: 20px; + padding: 12px 22px; + font-size: 0.905rem; + display: inline-block; + padding: 10px 15px 8px; + border-radius: 20px; + margin-bottom: 5px; + float: right; + clear: both; + max-width: 65%; + word-wrap: break-word; +} + +div.conv-form-wrapper div#messages div.message.to { + float: left; + background: lawngreen; + border-top-left-radius: 0; +} + +div.conv-form-wrapper div#messages div.message.from { + background: dodgerblue; + color: #fff; + border-top-right-radius: 0; +} + +.message.to+.message.from, .message.from+.message.to { + margin-top: 15px; +} + +@keyframes slideTop { + 0% { + margin-bottom: -25px; + } + + 100% { + margin-bottom: 0; + } +} + +@keyframes bounceIn { + 0% { + transform: scale(0.75, 0.75); + } + + 100% { + transform: scale(1.0, 1.0); + } +} + +.convFormDynamic button.submit { + position: absolute; + bottom: 0px; + border: none; + left:95%; + margin: 5px; + color: #fff; + cursor: pointer; + border-radius: 8px; + font-size: 1.6rem; + width: 50px; + height: 42px; + border: 1px solid #b7b7b7; + background: #c3c3c3; + outline: none !important; +} + +.center-block { + margin-right: 0; + margin-left: 0; + float: none; + text-align: center; +} + +button.submit.glow { + border: 1px solid dodgerblue !important; + background: dodgerblue !important; + box-shadow: 0 0 5px 2px rgba(14, 144, 255,0.4); +} +.no-border { + border: none !important; +} + +.dragscroll { + cursor: grab; +} + +div.conv-form-wrapper div#messages::-webkit-scrollbar, div#feed ul::-webkit-scrollbar { + width: 0px; + /* remove scrollbar space */ + background: transparent; + /* optional: just make scrollbar invisible */ +} + +span.clear { + display: block; + clear: both; +} diff --git a/channel/http/static/1.js b/channel/http/static/1.js new file mode 100644 index 00000000..e0da6358 --- /dev/null +++ b/channel/http/static/1.js @@ -0,0 +1,134 @@ + +function ConvState(wrapper, form, params) { + this.id='xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { + var r = Math.random() * 16 | 0, + v = c == 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); + this.form = form; + this.wrapper = wrapper; + this.parameters = params; + this.scrollDown = function () { + $(this.wrapper).find('#messages').stop().animate({ scrollTop: $(this.wrapper).find('#messages')[0].scrollHeight }, 600); + }.bind(this); +}; +ConvState.prototype.printAnswer = function (answer = '我是ChatGPT, 一个由OpenAI训练的大型语言模型, 我旨在回答并解决人们的任何问题,并且可以使用多种语言与人交流。') { + setTimeout(function () { + var messageObj = $(this.wrapper).find('.message.typing'); + answer = marked.parse(answer); + messageObj.html(answer); + messageObj.removeClass('typing').addClass('ready'); + this.scrollDown(); + $(this.wrapper).find(this.parameters.inputIdHashTagName).focus(); + }.bind(this), 500); +}; +ConvState.prototype.sendMessage = function (msg) { + var message = $('
'); + + $('button.submit').removeClass('glow'); + $(this.wrapper).find(this.parameters.inputIdHashTagName).focus(); + setTimeout(function () { + $(this.wrapper).find("#messages").append(message); + this.scrollDown(); + }.bind(this), 100); + + var messageObj = $(' '); + setTimeout(function () { + $(this.wrapper).find('#messages').append(messageObj); + this.scrollDown(); + }.bind(this), 150); + var _this = this + $.ajax({ + url: "./chat", + type: "POST", + timeout:60000, + data: JSON.stringify({ + "id": _this.id, + "msg": msg + }), + contentType: "application/json; charset=utf-8", + dataType: "json", + success: function (data) { + _this.printAnswer(data.result) + }, + error:function () { + _this.printAnswer("网络故障,对话未送达") + }, + }) +}; +(function ($) { + $.fn.convform = function () { + var wrapper = this; + $(this).addClass('conv-form-wrapper'); + + var parameters = $.extend(true, {}, { + placeHolder: 'Type Here', + typeInputUi: 'textarea', + formIdName: 'convForm', + inputIdName: 'userInput', + buttonText: '▶' + }); + + //hides original form so users cant interact with it + var form = $(wrapper).find('form').hide(); + + var inputForm; + parameters.inputIdHashTagName = '#' + parameters.inputIdName; + inputForm = $('