本节主题是项目回顾,从总体上分析QQ机器人的数据流
1. 项目的生命周期:
/**
* 下面是我的理解和注释:
* 本模块功能:提供各种qq服务的基础函数库
* 项目的运行流程是:
* 比如,我现在1.0版最主要的一个功能就是:接受群消息,并自动回复
* 它的流程是:
* 0. 系统在哪里加载了XiaoVGetUpServlet呢???{因为整个项目只有XiaoVGetUpServlet调用了QQService.java},而QQService必须启动
* 答案见:XiaoVGetUpServlet.java中我的注释 {其实是一个非常简单的机制,在下面}
*
* 1. 本项目的web.xml中定义了XiaoVGetUpServlet.java的map,系统在latke.properties中定义了项目启动的第一个request,然后导向到DispatcherServlet,依次导向到QRCodeShowServlet,然后是XiaoVGetUpServlet
* 2. XiaoVGetUpServlet中使用了QQService的initQQClient,initQQClient中定义了用于回复消息的callback{即onGroupMessage};initQQClient又使用了SmartQQClient
* 3. SmartQQClient会做三件事:
* 每次new一个SmartQQClient,就回去执行同名的构造方法SmartQQClient
* 1. 登陆
* 2. 开启一个守护线程,循环地跑:做一件事:接收消息pollMessage
* 3. 本执行callback {即onGroupMessage}
* 4. onGroupMessage又会去调用onQQGroupMessage,{这两个名字很像,不要看错了,我一开始就看晕了。。。}
* 5. onQQGroupMessage依次做了三件事:1. 判断提问是否“感兴趣”;2.如果感兴趣就调用answer获得提问的答案;3.并把答案给sendMessageToGroup
* 6. sendMessageToGroup会调用SmartQQClient类的sendMessageToGroup{好坑,同名的。。。},把答案以response的形式发送到对应群
*
*/
2. 对应的核心代码分析:
2.1 QQService.java
... //省略一部分非核心代码,见源码
@Service
public class QQService {
...// 省略
/**
* Initializes QQ client.
*/
public void initQQClient() {
LOGGER.info("开始初始化小薇");
xiaoV = new SmartQQClient(new MessageCallback() {
// 有个疑问:这里new了一个SmartQQClient,而SmartQQClient的构造方法里面是有login的,但是事实是我只登陆了一次
@Override
public void onMessage(final Message message) {
new Thread(() -> {
try {
Thread.sleep(500 + RandomUtils.nextInt(1000));
final String content = message.getContent();
final String key = XiaoVs.getString("qq.bot.key");
if (!StringUtils.startsWith(content, key)) { // 不是管理命令,只是普通的私聊
// 让小薇进行自我介绍
xiaoV.sendMessageToFriend(message.getUserId(), XIAO_V_INTRO);
return;
}
final String msg = StringUtils.substringAfter(content, key);
LOGGER.info("Received admin message: " + msg);
sendToPushQQGroups(msg);
} catch (final Exception e) {
LOGGER.log(Level.ERROR, "XiaoV on group message error", e);
}
}).start();
}
@Override
public void onGroupMessage(final GroupMessage message) {
new Thread(() -> {
try {
Thread.sleep(500 + RandomUtils.nextInt(1000));
onQQGroupMessage(message);
} catch (final Exception e) {
LOGGER.log(Level.ERROR, "XiaoV on group message error", e);
}
}).start();
}
@Override
public void onDiscussMessage(final DiscussMessage message) {
new Thread(() -> {
try {
Thread.sleep(500 + RandomUtils.nextInt(1000));
onQQDiscussMessage(message);
} catch (final Exception e) {
LOGGER.log(Level.ERROR, "XiaoV on group message error", e);
}
}).start();
}
});
reloadGroups();
reloadDiscusses();
LOGGER.info("小薇初始化完毕");
}
...//省略
private void sendMessageToGroup(final Long groupId, final String msg) {
Group group = QQ_GROUPS.get(groupId);
if (null == group) {
reloadGroups();
group = QQ_GROUPS.get(groupId);
}
if (null == group) {
LOGGER.log(Level.ERROR, "Group list error [groupId=" + groupId + "], 请先参考项目主页 FAQ 解决"
+ "(https://github.com/b3log/xiaov#报错-group-list-error-groupidxxxx-please-report-this-bug-to-developer-怎么破),"
+ "如果还有问题,请到论坛讨论帖中进行反馈(https://hacpai.com/article/1467011936362)");
return;
}
LOGGER.info("Pushing [msg=" + msg + "] to QQ qun [" + group.getName() + "]");
xiaoV.sendMessageToGroup(groupId, msg);
}
..//省略
/*
* 这是我的注释1.0
* 这个函数非常重要,我会抽时间把这个函数讲清楚 TODO
* */
private void onQQGroupMessage(final GroupMessage message) throws SQLException {
final long groupId = message.getGroupId();
final long userId = message.getUserId();// 获取消息的sender的QQ号,与机器人的QQ做比较
//LOGGER.debug(Long.toString(userId));
//final long botId = XiaoVs.getInt("qq.bot.id");//从配置文件中读当前机器人的QQ号 {还有点bug,后面再修}
// 为了解决2872995315溢出的问题,只能把userId和机器人ID比较由 Long比较 转化成 字符串比较
String s_userId = Long.toString(userId);
//final String s_botId = "2872995315";//暂时写死
final String s_botId = XiaoVs.getString("qq.bot.id"); //从xiaov.properties配置文件中读
final String content = message.getContent();
final String userName = Long.toHexString(message.getUserId());
// Push to third system
String qqMsg = content.replaceAll("\["face",[0-9]+\]", "");
if (StringUtils.isNotBlank(qqMsg)) {
qqMsg = "<p>" + qqMsg + "</p>";
sendToThird(qqMsg, userName);
}
String msg = "";
// 下面是对于QQ用户提问的语句进行合法性分析,如果符合规则,那就收集答案,并发送到QQ群 {要避免机器人自问自答的情况发生}
/*
if (StringUtils.contains(content, XiaoVs.QQ_BOT_NAME)
|| (StringUtils.length(content) > 6
&& (StringUtils.contains(content, "?") || StringUtils.contains(content, "?") || StringUtils.contains(content, "问")))) {
msg = answer(content, userName);
}*/
if ( StringUtils.contains(content, XiaoVs.QQ_BOT_NAME) // TODO:这里是对提问的基本要求{过滤不合法的提问}
|| (StringUtils.length(content) > 0) && !(s_userId.equals(s_botId)) ) { //彻底解决了机器人自问自答的bug
msg = answer(content, userName);
}
if (StringUtils.isBlank(msg)) {
return;
}
if (RandomUtils.nextFloat() >= 0.9) {
Long latestAdTime = GROUP_AD_TIME.get(groupId);
if (null == latestAdTime) {
latestAdTime = 0L;
}
final long now = System.currentTimeMillis();
if (now - latestAdTime > 1000 * 60 * 30) {
msg = msg + "。
" + ADS.get(RandomUtils.nextInt(ADS.size()));
GROUP_AD_TIME.put(groupId, now);
}
}
sendMessageToGroup(groupId, msg);
}
...//省略
/*
* 这是我对xiaov-1.0的注释1.0
* 这个函数非常重要,定义了提问和回答这两个功能的数据结构及数据来源
* 我会抽时间把这个函数讲清楚 TODO
* */
private String answer(final String content, final String userName) throws SQLException {
if (keywords.size() == 0) // 加载一次即可
{
// 第一次使用,注册下jdbc驱动,通过new一个AnswersFromSQLite对象来激发static代码块
// AnswersFromSQLite namexxx = new AnswersFromSQLite();
// 获取keys,只调用一次
keywords = AnswersFromSQLite.getAllKeys();
}
// LOGGER.debug(keywords.get(0));// 测试下content
String keyword = "";
for (final String kw : keywords) {
if (StringUtils.containsIgnoreCase(content, kw)) {
keyword = kw;
break;
}
}
// LOGGER.debug(content);// 测试下content
// LOGGER.debug(keyword);// 测试下keyword有没有捕捉到
String ret = "";
String msg = replaceBotName(content);
if (StringUtils.isNotBlank(keyword)) {
try {
ret = AnswersFromSQLite.getValue(keyword);//我自定义的回复函数
ret= URLEncoder.encode(ret, "UTF-8");
} catch (final UnsupportedEncodingException e) {
LOGGER.log(Level.ERROR, "Search key encoding failed", e);
}
} else if (StringUtils.contains(content, XiaoVs.QQ_BOT_NAME) && StringUtils.isNotBlank(msg)) {
if (1 == QQ_BOT_TYPE && StringUtils.isNotBlank(userName)) {
... //省略
}
try {
ret= URLDecoder.decode(ret, "UTF-8");
} catch (final UnsupportedEncodingException e) {
LOGGER.log(Level.ERROR, "ret decoding failed", e);
}
return ret;
}
... //省略
}
2.2 XiaoVGetUpServlet.java
/**
* 下面是我的注释:
* 在web.xml定义了一个Servlet配置项,就是把一个url路由和这个XiaoVGetUpServlet类绑定了,后来只要访问那个url,就跳转到这个class来处理
* 问题来了,访问那个url被谁读了呢?我需要进一步看源码找答案!!!
* 上述的疑问其实是对jvm加载web.xml的机制不熟悉导致的:
* 但是:
* 不用管,因为本项目的web.xml配置了XiaoVGetUpServlet为<load-on-startup>3</load-on-startup>,就是说优秀级为第3自动加载
* 一旦项目启动,读取web.xml,然后等待优先级到了3,就自动调用XiaoVGetUpServlet的init(),接下来的流程见QQService.java中我的描述
*/
public class XiaoVGetUpServlet extends HttpServlet {
/**
* Serial version UID.
*/
private static final long serialVersionUID = 1L;
/**
* Logger.
*/
private static final Logger LOGGER = Logger.getLogger(XiaoVGetUpServlet.class);
/**
* Bean manager.
*/
private LatkeBeanManager beanManager;
@Override
public void init() throws ServletException {
new Thread(() -> {
try {
Thread.sleep(3000);
} catch (final Exception e) {
LOGGER.log(Level.ERROR, e.getMessage());
}
beanManager = Lifecycle.getBeanManager();
final QQService qqService = beanManager.getReference(QQService.class);
qqService.initQQClient();
}).start();
}
}
2.3 SmartQQClient.java
/**
* 下面是我的理解和注释:
* Api客户端.
* 每次new一个SmartQQClient,就回去执行同名的构造方法SmartQQClient
* 1. 登陆
* 2. 开启一个守护线程,循环地跑:做一件事:接收消息pollMessage
* 3. 本执行callback
*/
public class SmartQQClient implements Closeable {
...//省略
//线程开关
private volatile boolean pollStarted;
/*
* 每次new 一个SmartQQClient就自动执行构造函数
* */
public SmartQQClient(final MessageCallback callback) {
this.client = Client.pooled().maxPerRoute(5).maxTotal(10).build();
this.session = client.session();
login();
if (callback != null) {
this.pollStarted = true;
new Thread(new Runnable() {
@Override
public void run() {
while (true) { //这就是循环接受群消息的核心所在;这个线程永远在循环地 接收群消息{即循环地监听}
if (!pollStarted) {
return;
}
try {
pollMessage(callback); //仅仅是接受消息,callback中会定义如何解析消息和回复消息
} catch (RequestException e) {
//忽略SocketTimeoutException
if (!(e.getCause() instanceof SocketTimeoutException)) {
LOGGER.error(e.getMessage());
}
} catch (Exception e) {
LOGGER.error(e.getMessage());
}
}
}
}).start();
}
}
/**
* 登录
*/
private void login() {
getQRCode();
String url = verifyQRCode();
getPtwebqq(url);
getVfwebqq();
getUinAndPsessionid();
getFriendStatus(); //修复Api返回码[103]的问题
//登录成功欢迎语
UserInfo userInfo = getAccountInfo();
LOGGER.info(userInfo.getNick() + ",欢迎!");
}
//登录流程1:获取二维码
private void getQRCode() {
LOGGER.debug("开始获取二维码");
//本地存储二维码图片
String filePath;
try {
filePath = new File("qrcode.png").getCanonicalPath();
} catch (IOException e) {
throw new IllegalStateException("二维码保存失败");
}
Response response = session.get(ApiURL.GET_QR_CODE.getUrl())
.addHeader("User-Agent", ApiURL.USER_AGENT)
.file(filePath);
for (Cookie cookie : response.getCookies()) {
if (Objects.equals(cookie.getName(), "qrsig")) {
qrsig = cookie.getValue();
break;
}
}
LOGGER.info("二维码已保存在 " + filePath + " 文件中,请打开手机QQ并扫描二维码");
}
//用于生成ptqrtoken的哈希函数
private static int hash33(String s) {
int e = 0, n = s.length();
for (int i = 0; n > i; ++i)
e += (e << 5) + s.charAt(i);
return 2147483647 & e;
}
//登录流程2:校验二维码
private String verifyQRCode() {
LOGGER.debug("等待扫描二维码");
//阻塞直到确认二维码认证成功
while (true) {
sleep(1);
Response<String> response = get(ApiURL.VERIFY_QR_CODE, hash33(qrsig));
String result = response.getBody();
if (result.contains("成功")) {
for (String content : result.split("','")) {
if (content.startsWith("http")) {
LOGGER.info("正在登录,请稍后");
return content;
}
}
} else if (result.contains("已失效")) {
LOGGER.info("二维码已失效,尝试重新获取二维码");
getQRCode();
}
}
}
//登录流程3:获取ptwebqq
private void getPtwebqq(String url) {
LOGGER.debug("开始获取ptwebqq");
Response<String> response = get(ApiURL.GET_PTWEBQQ, url);
this.ptwebqq = response.getCookies().get("ptwebqq").iterator().next().getValue();
}
//登录流程4:获取vfwebqq
private void getVfwebqq() {
LOGGER.debug("开始获取vfwebqq");
Response<String> response = get(ApiURL.GET_VFWEBQQ, ptwebqq);
int retryTimes4Vfwebqq = retryTimesOnFailed;
while (response.getStatusCode() == 404 && retryTimes4Vfwebqq > 0) {
response = get(ApiURL.GET_VFWEBQQ, ptwebqq);
retryTimes4Vfwebqq--;
}
this.vfwebqq = getJsonObjectResult(response).getString("vfwebqq");
}
//登录流程5:获取uin和psessionid
private void getUinAndPsessionid() {
LOGGER.debug("开始获取uin和psessionid");
JSONObject r = new JSONObject();
r.put("ptwebqq", ptwebqq);
r.put("clientid", Client_ID);
r.put("psessionid", "");
r.put("status", "online");
Response<String> response = post(ApiURL.GET_UIN_AND_PSESSIONID, r);
JSONObject result = getJsonObjectResult(response);
this.psessionid = result.getString("psessionid");
this.uin = result.getLongValue("uin");
}
/**
* 获取群列表
*
* @return
*/
public List<Group> getGroupList() {
LOGGER.debug("开始获取群列表");
JSONObject r = new JSONObject();
r.put("vfwebqq", vfwebqq);
r.put("hash", hash());
Response<String> response = post(ApiURL.GET_GROUP_LIST, r);
int retryTimes4getGroupList = retryTimesOnFailed;
while (response.getStatusCode() == 404 && retryTimes4getGroupList > 0) {
response = post(ApiURL.GET_GROUP_LIST, r);
retryTimes4getGroupList--;
}
JSONObject result = getJsonObjectResult(response);
return JSON.parseArray(result.getJSONArray("gnamelist").toJSONString(), Group.class);
}
/**
* 拉取消息
*
* @param callback 获取消息后的回调
*
* 下面是我的注释:
* 这个函数非常重要,有时间需要把它讲清楚 TODO
*/
private void pollMessage(MessageCallback callback) {
LOGGER.debug("开始接收消息");
JSONObject r = new JSONObject();
r.put("ptwebqq", ptwebqq);
r.put("clientid", Client_ID);
r.put("psessionid", psessionid);
r.put("key", "");
// 先用post(){本质是post方式}把r发给ApiURL.POLL_MESSAGE,得到response
Response<String> response = post(ApiURL.POLL_MESSAGE, r);
JSONArray array = getJsonArrayResult(response);// 改造成JsonArray格式
for (int i = 0; array != null && i < array.size(); i++) {
JSONObject message = array.getJSONObject(i);
String type = message.getString("poll_type");
if ("message".equals(type)) { // 确认是message是qq私聊的消息类型
callback.onMessage(new Message(message.getJSONObject("value")));
} else if ("group_message".equals(type)) { //qq群消息 {这也是目前我使用和研究的模块:QQ群}
callback.onGroupMessage(new GroupMessage(message.getJSONObject("value")));
} else if ("discu_message".equals(type)) { //qq讨论组消息
callback.onDiscussMessage(new DiscussMessage(message.getJSONObject("value")));
}
}
}
/**
* 发送群消息
*
* @param groupId 群id
* @param msg 消息内容
*
*
* 下面是我的注释:
* 这个函数也很重要,抽时间讲清楚 TODO
*/
public void sendMessageToGroup(long groupId, String msg) {
LOGGER.debug("开始发送群消息");
JSONObject r = new JSONObject();
r.put("group_uin", groupId);
r.put("content", JSON.toJSONString(Arrays.asList(msg, Arrays.asList("font", Font.DEFAULT_FONT)))); //注意这里虽然格式是Json,但是实际是String
LOGGER.debug(r.get("content"));
r.put("face", 573);
r.put("clientid", Client_ID);
r.put("msg_id", MESSAGE_ID++);
r.put("psessionid", psessionid);
Response<String> response = postWithRetry(ApiURL.SEND_MESSAGE_TO_GROUP, r);
checkSendMsgResult(response);
}
}
2.4 web.xml
<listener>
<listener-class>org.b3log.xiaov.XiaoVServletListener</listener-class>
</listener>
<filter>
<filter-name>EncodingFilter</filter-name>
<filter-class>org.b3log.latke.servlet.filter.EncodingFilter</filter-class>
<init-param>
<param-name>requestEncoding</param-name>
<param-value>UTF-8</param-value>
</init-param>
<init-param>
<param-name>responseEncoding</param-name>
<param-value>UTF-8</param-value>
</init-param>
</filter>
<filter-mapping>
<filter-name>EncodingFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
<session-config>
<session-timeout>
60
</session-timeout>
</session-config>
<servlet>
<servlet-name>DispatcherServlet</servlet-name> <!-- 项目启动后第1个就加载这个调度器,执行它的init -->
<servlet-class>org.b3log.latke.servlet.DispatcherServlet</servlet-class>
<load-on-startup>1</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>DispatcherServlet</servlet-name>
<url-pattern>/*</url-pattern>
</servlet-mapping>
<servlet>
<servlet-name>XiaoVGetUpServlet</servlet-name> <!-- 项目启动后第3个就加载这个小薇机器人的唤醒 -->
<servlet-class>org.b3log.xiaov.processor.XiaoVGetUpServlet</servlet-class>
<load-on-startup>3</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>XiaoVGetUpServlet</servlet-name>
<url-pattern>/getup</url-pattern>
</servlet-mapping>
<servlet>
<servlet-name>QRCodeShowServlet</servlet-name> <!-- 项目启动后第2个就加载这个二维码处理器 -->
<servlet-class>org.b3log.xiaov.processor.QRCodeShowServlet</servlet-class>
<load-on-startup>2</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>QRCodeShowServlet</servlet-name>
<url-pattern>/login</url-pattern>
</servlet-mapping>
</web-app>
2.5 我的回复函数AnswersFromSQLite.java
...//省略
public class AnswersFromSQLite {
public static String getValue(String key) throws SQLException {
// 测试查询某条记录
Dao<t_answers, Integer> dao = getDao();
List<t_answers> ans = queryByOPtions(dao, key);
// logger.info(ans.get(0).getValue());
if (ans != null) {
return ans.get(0).getValue(); // 仅返回第一条记录的value字段
}
return null;
}
public static List<String> getAllKeys() throws SQLException {
Dao<t_answers, Integer> dao = getDao();
List<t_answers> t_answers = queryByOPtions(dao);
List<String> keys = new ArrayList<String>();
for (t_answers t : t_answers) {
keys.add(t.getKey());
// logger.info(t.getKey());
}
return keys;
}
private static List<t_answers> queryByOPtions(Dao<t_answers, Integer> dao) throws SQLException {
//按条件查询多条记录并分页并倒序 这里用到QueryBuilder
QueryBuilder<t_answers, Integer> queryBuilder = dao.queryBuilder();
//queryBuilder.where().eq("is_delete", 0).and().eq("status", 0);
//queryBuilder.limit((long) 10);
queryBuilder.where().eq("is_delete", 0); // 执行逻辑上的“有效”查询,以后删除也是“逻辑删除” {除非不得已,不要物理删除}
queryBuilder.orderBy("id", false);
List<t_answers> ts = dao.query(queryBuilder.prepare());
return ts;
}
private static List<t_answers> queryByOPtions(Dao<t_answers, Integer> dao, String key) throws SQLException {
//按条件查询多条记录并分页并倒序 这里用到QueryBuilder
QueryBuilder<t_answers, Integer> queryBuilder = dao.queryBuilder();
//queryBuilder.where().eq("is_delete", 0).and().eq("status", 0);
//queryBuilder.limit((long) 10);
queryBuilder.where().eq("is_delete", 0).and().eq("key", key);
queryBuilder.orderBy("id", false);
List<t_answers> t_answerss = dao.query(queryBuilder.prepare());
return t_answerss;
}
private static Dao<t_answers, Integer> getDao() throws SQLException {
// //E:/Software_install/SQLite/Repo/d_xiaov.db
String databaseUrl = "jdbc:sqlite:D:/Myeclipse15/Workspace/db/d_xiaov.db";//d_xiaov.db放在当前项目根目录下 //【只能写绝对路径,否则SQLite报错】
//创建一个JDBC连接
ConnectionSource connectionSource = new JdbcConnectionSource(databaseUrl);
// logger.info(connectionSource.toString());
//删除表同时忽略错误
//TableUtils.dropTable(connectionSource, t_answers.class, true);
//创建Table
//TableUtils.createTable(connectionSource, t_answers.class);
//实例化一个DAO,对表进行数据操作
Dao<t_answers, Integer> dao = DaoManager.createDao(connectionSource, t_answers.class);
return dao;
}
}
2.6 Some Tricks:
1.注意小坑:本项目中Application,java只是SmartQQ协议给的测试demo,与本项目无关,不要被这个名字和它的main方法迷惑(它其实和入口文件甚至是本项目没有任何关系,删掉都可以的)
2.注意一个【大坑】:SQLite数据库在win10下不能使用相对路径创建DB_PATH,【必须写绝对路径】,否则会报错:“找不到表”(其实实际表和数据库及代码都很正常)
3.更多细节:其他函数均属于非核心部分(对实现【群消息自动回复】这个需求而言),我做了部分注释,直接写在每个文件中,有兴趣可以翻看。
3. web.xml的配置与java web 项目启动:
1.java项目的启动入口
2.severlet
3.listenr
4.filter参考:
https://blog.csdn.net/fjtnylk/article/details/50717753
https://blog.csdn.net/reggergdsg/article/details/52698022
https://blog.csdn.net/guihaijinfen/article/details/8363839
http://www.blogjava.net/xzclog/archive/2011/09/29/359789.html
https://www.cnblogs.com/whgk/p/6399262.html
https://blog.csdn.net/xuke6677/article/details/44752207
https://www.cnblogs.com/ygj0930/p/6374384.html
https://blog.csdn.net/reggergdsg/article/details/52891311
https://blog.csdn.net/reggergdsg/article/details/52821502
https://blog.csdn.net/reggergdsg/article/details/52962774
https://blog.csdn.net/reggergdsg/article/details/53024827
4. 总结:
- 目前已实现【关键字,提问和答案】从sqlite读入
- TODO: 如某同事0所言,理想的交互方式如下:用户提一个问题,机器人分析用户的提问中的关键字集合,然后返回几个完整的提问句子及其对应答案;返回用户若干个【提示性的提问句子集合】并且每个提问后面附带【答案的编号】,然后用户二次回复【答案编号】,机器人再次回复一个完整的答案句子。
- TODO:项目部署到阿里云【先用滴滴云练手】
- TODO:接入百度NLP,实现分词和智能API回复