七夕将至,又到了各位程序猿给女朋友,老婆送礼物的节日。今年老婆规定了,不能花费太多钱,还禁止买淘宝直男礼物。真的太难了😿,想破头皮也不知道送啥好,头发却已经掉了一缕又一缕,什么代码绽开烟花,照片墙,哄老婆的机器人都作过了。此次怎么办呢,又不让花钱,又要有想法,看来只能祭起个人大杀器,码代码过七夕了。看到老婆以前喜欢玩抖音,P照片。还常常会用到人脸卡通化,人脸年龄变化,人脸性别变化的特效。那我就想,何不作一个微信机器人,你发照片我帮你自动生成特效,不用任何APP就能实现,还能让老婆拉闺蜜建个微信群一块玩。javascript
想好了就开干,以前写过一个《三步教你用Node作一个微信哄女朋友(基友)神器》,因此此次再写一个机器人也不算太难,只是要提早找好相应的图片生成接口才行,通过一番资料查找,发现腾讯云有我的脸变换的功能,通过测试后,发现就是我想要的功能,并且效果还不错,关键是每月有 1000 次的免费额度,这就很香了。三种转换模式就是 3000 次,白嫖不香么 😏,白嫖腾讯这就更香了,哈哈java
本次实现的主要功能是发送照片,根据选择生成对应的特效。微信机器人的主要实现用的仍是Wechaty,协议是基于免费版web协议的,因此不用担忧没有Wechaty的付费token,若是说你的微信无法登录网页版微信,不要紧wechaty-puppet-wechat
协议是基于 UOS 桌面版的,新帐号也能够用。node
已实现功能:python
私聊和群内均可以实现照片特效实现git
登陆腾讯云帐号,没有就直接 QQ 登陆,直接点击管理控制台开通便可,不用付费,也不用选资源包,开通后自动有每月 1000 次的免费额度,若是本身和朋友玩彻底足够了。若是你是想活跃社群或者土豪,就随便充值了github
访问此页面https://console.cloud.tencent.com/cam/capi获取你的secretid
和secretkey
,配置插件的时候须要用的到web
node环境须要本身配置一下,node>=14,。新建一个文件夹face-carton
,在文件夹内部执行npm init
,一路回车便可docker
这里说明一下,头像转化插件wechaty-face-carton
就是我此次作的主要功能,已经开源在github,因为已经发布到npm,因此这里你只须要安装就可使用了,对于不关心代码的童鞋,直接安装使用就好了。若是想知道代码怎么实现的,能够到github仓库查看一下源码。对于源码的实现,文后我也会放一部分核心代码进行说明。npm
配置 npm
源为淘宝源(重要,由于须要安装 chromium
,不配置的话下载会失败或者速度很慢,由于这个玩意 140M 左右)小程序
npm config set registry https://registry.npm.taobao.org npm config set disturl https://npm.taobao.org/dist npm config set puppeteer_download_host https://npm.taobao.org/mirrors npm install wechaty wechaty-face-carton wechaty-puppet-wechat --save
若是安装出现问题,建议删除node_modules
后多试几回,对于其余环境问题能够参考:
常见问题处理和 wechaty官网
目录下新建文件index.js
const { Wechaty } = require('wechaty') const WechatyFaceCartonPlugin = require('wechaty-face-carton') const name = 'wechat-carton' const bot = new Wechaty({ name, puppet: 'wechaty-puppet-wechat' }) bot .use( WechatyFaceCartonPlugin({ maxuser: 20, // 支持最多多少人进行对话,建议不要设置太多,不然占用内存会增长 secretId: '腾讯secretId', // 腾讯secretId secretKey: '腾讯secretKey', // 腾讯secretKey allowUser: ['Leo_chen'], // 容许哪些好友使用人像漫画化功能,为空[]表明全部人开启 allowRoom: ['测试1'], // 容许哪些群使用人像漫画化功能,为空[]表明不开启任何一个群 quickModel: true, // 快速体验模式 默认关闭 开启后可直接生成二维码扫描体验,若是本身代码有登陆逻辑能够不配置此项 tipsword: '卡通', // 私聊发送消息,触发照片卡通化提示 若是直接发送图片,默认进入图片卡通化功能,不填则当用户初次发送文字消息时不作任何处理 }) ) .start() .catch((e) => console.error(e))
参数名 | 必填 | 默认值 | 说明 |
---|---|---|---|
maxuser | 否 | 20 | 支持最多多少人进行对话,建议不要设置太多,不然占用内存会增长 |
secretId: | 是 | '' | 腾讯 secretId |
secretKey | 是 | '' | 腾讯 secretKey |
allowUser | 否 | [] | 容许哪些好友使用人像漫画化功能,为空[]表明全部人开启 |
allowRoom | 否 | [] | 容许哪些群使用人像漫画化功能,为空[]表明不开启任何一个群 |
quickModel | 否 | false | 快速体验模式 默认关闭 开启后可直接生成二维码扫描体验,若是本身代码有登陆逻辑能够不配置此项,若是是单独使用此插件,建议开启 |
tipsword | 否 | '卡通' | 私聊发送消息,触发照片卡通化提示。若是直接发送图片,默认进入图片卡通化功能,不填则当用户初次发送文字消息时不作任何处理,建议填写触发关键词 |
node index.js
扫码登陆后,给小助手发送图片,便可转化图片,对于不能转化的图片,小助手会给出缘由
若是遇到过多的环境问题让你很是苦恼,你也能够在以上第三步完成后,根目录新建一个Dockerfile
文件,里面填入内容,对!就一行就行!
FROM wechaty/onbuild
完成后就能够直接build镜像
docker build -t wechaty-carton .
build完成后就能够直接run后扫码了
docker run wechaty-carton
插件源码地址:https://github.com/leochen-g/wechaty-face-carton,若是能帮你哄女友开心,麻烦给个star,当心心❤送给你 😏
插件主入口为index.js
,service/tencent.js
为调用腾讯云服务的主要方法,service/multiReply.js
是多轮对话实现的核心,util/index.js
为一些公共的处理方法,包括群发消息,私聊消息的公共方法抽取。
消息监听很简单,Wechaty暴露出message
事件,只要根据消息类型进行过滤便可,对于本插件而言,图片消息是触发转化的关键
const { contactSay, roomSay, delay } = require('./util/index') const { BotManage } = require('./service/multiReply') const Qrterminal = require('qrcode-terminal') let config = {} let BotRes = '' /** * 根据消息类型过滤私聊消息事件 * @param {*} that bot实例 * @param {*} msg 消息主体 */ async function dispatchFriendFilterByMsgType(that, msg) { try { const type = msg.type() const contact = msg.talker() // 发消息人 const name = await contact.name() const isOfficial = contact.type() === that.Contact.Type.Official const id = await contact.id switch (type) { // 文字消息处理 case that.Message.Type.Text: content = msg.text() if (!isOfficial) { console.log(`发消息人${name}:${content}`) if (content.trim()) { const multiReply = await BotRes.run(id, { type: 1, content }) let replys = multiReply.replys let replyIndex = multiReply.replys_index await delay(1000) await contactSay(contact, replys[replyIndex]) } } break // 图片消息处理 case that.Message.Type.Image: console.log(`发消息人${name}:发了一张图片`) // 判断是否配置了指定人开启转换 if (!config.allowUser.length || config.allowUser.includes(name)) { const file = await msg.toFileBox() const base = await file.toDataURL() const multiReply = await BotRes.run(id, { type: 3, url: base }) let replys = multiReply.replys let replyIndex = multiReply.replys_index await delay(1000) await contactSay(contact, replys[replyIndex]) } else { console.log(`没有开启 ${name} 的人脸漫画化功能, 或者检查是否已经配置此人微信昵称`) } break default: break } } catch (error) { console.log('监听消息错误', error) } } /** * 根据消息类型过滤群消息事件 * @param {*} that bot实例 * @param {*} room room对象 * @param {*} msg 消息主体 */ async function dispatchRoomFilterByMsgType(that, room, msg) { const contact = msg.talker() // 发消息人 const contactName = contact.name() const roomName = await room.topic() const type = msg.type() const userName = await contact.name() const userSelfName = that.userSelf().name() const id = await contact.id switch (type) { // 文字消息处理 case that.Message.Type.Text: content = msg.text() console.log(`群名: ${roomName} 发消息人: ${contactName} 内容: ${content}`) // 判断是否配置了指定群开启转换 if (config.allowRoom.includes(roomName)) { const mentionSelf = content.includes(`@${userSelfName}`) if (mentionSelf) { content = content.replace(/@[^,,::\s@]+/g, '').trim() if (content) { const multiReply = await BotRes.run(id, { type: 1, content }) let replys = multiReply.replys let replyIndex = multiReply.replys_index await delay(1000) await roomSay(room, contact, replys[replyIndex]) } } } break // 图片消息处理 case that.Message.Type.Image: console.log(`群名: ${roomName} 发消息人: ${contactName} 发了一张图片`) // 判断是否配置了指定群开启转换 if (config.allowRoom.includes(roomName)) { console.log(`匹配到群:${roomName}的人脸漫画化功能已开启,正在生成中...`) const file = await msg.toFileBox() const base = await file.toDataURL() const multiReply = await BotRes.run(id, { type: 3, url: base }) let replys = multiReply.replys let replyIndex = multiReply.replys_index await delay(1000) await roomSay(room, contact, replys[replyIndex]) } else { console.log('没有开通此群人脸漫画化功能') } break default: break } } /** * 消息事件监听 * @param {*} msg * @returns */ async function onMessage(msg) { try { if (!BotRes) { BotRes = new BotManage(config.maxuser, this, config) } const room = msg.room() // 是否为群消息 const msgSelf = msg.self() // 是否本身发给本身的消息 if (msgSelf) return // 根据不一样消息类型进行消息的派发处理 if (room) { dispatchRoomFilterByMsgType(this, room, msg) } else { dispatchFriendFilterByMsgType(this, msg) } } catch (e) { console.log('reply error', e) } } .....
对于多轮对话的实现,我是参考大佬@kevinfu1717的python版Wechaty的代码,把他python代码中的多轮对话的核心代码转换成了js版,具体实现逻辑呢,我就引用他的解释,一些对应js中的方法名我进行了修改。若是有对python实现有兴趣的能够访问https://github.com/kevinfu1717/multimediaChatbot
service/multiReply.js
文件
- multiReply中的MultiReply使用相似“简易工厂模式”。(熟悉工厂模式的筒子能够忽略本段)。每个触发聊天的用户都会生成一个user_bot,用户的输入就好像工厂里面的原材料,通过BotManage分配到各个工序的工人(各个技能模块,如:卡通人脸生成、人脸年龄变化、人脸性别变化等)进行处理,最终组装好的产品给到用户。不一样用户的输入就像不一样的原材料,不断送进工厂处理,流水的bot铁打不变的BotManage,而每一个user_bot装载的是整个聊天过程当中的全部对话。以上纯属我的胡扯,工厂模式正规解释具体见:https://juejin.cn/post/6844903653774458888
const { generateCarton } = require('./tencent') class MultiReply { constructor() { this.userName = '' this.startTime = 0 // 开始时间 this.queryList = [] // 用户说的话 this.replys = [] // 每次回复,回复用户的内容(列表) this.reply_index = 0 // 回复用户的话回复到第几部分 this.step = 0 // 当前step this.stepRecord = [] // 经历过的step this.lastReply = {} // 最后回复的内容 this.imageData = '' // 用户发送的图片 this.model = 1 // 默认选择漫画模式 this.age = 60 // 用户选择的年龄 this.gender = 0 // 用户性别转换的模式 } paramsInit() { this.startTime = 0 // 开始时间 this.queryList = [] // 用户说的话 this.replys = [] // 每次回复,回复用户的内容(列表) this.reply_index = 0 // 回复用户的话回复到第几部分 this.step = 0 // 当前step this.stepRecord = [] // 经历过的step this.lastReply = {} // 最后回复的内容 this.imageData = '' // 用户发送的图片 this.model = 1 // 默认选择漫画模式 this.age = 60 // 用户选择的年龄 this.gender = 0 // 用户性别转换的模式 } } class BotManage { constructor(maxuser, that, config) { this.Bot = that this.config = config this.userBotDict = {} // 存放全部对话的用户 this.userTimeDict = {} this.maxuser = maxuser // 最大同时处理的用户数 this.loopLimit = 4 this.replyList = [ { type: 1, content: '请选择你要转换的模式(发送序号):\n\n[1]、卡通化照片\n\n[2]、变换年龄\n\n[3]、变换性别\n\n' }, { type: 1, content: '请输入你想要转换的年龄:请输入10~80的任意数字' }, { type: 1, content: '请输入你想转换的性别(发送序号):\n\n[0]、男变女\n\n[1]、女变男\n\n' }, { type: 1, content: '你输入的序号有误,请输入正确的序号' }, { type: 1, content: '你输入的年龄有误,请输入10~80的任意数字' }, { type: 1, content: '你选择的序号有误,请输入你想转换的性别(发送序号):\n\n[0]、男变女\n\n[1]、女变男\n\n' }, ] } async creatBot(username, content) { console.log('bot process create') this.userBotDict[username] = new MultiReply() this.userBotDict[username].userName = username this.userBotDict[username].imageData = content.url return await this.updateBot(username, content) } // 更新对话 async updateBot(username, content) { console.log(`更新{${username}}对话`) this.userTimeDict[username] = new Date().getTime() this.userBotDict[username].queryList.push(content) return await this.talk(username, content) } async talk(username, content) { // 防止进入死循环 if (this.userBotDict[username].stepRecord.length >= this.loopLimit) { const arr = this.userBotDict[username].stepRecord.slice(-1 * this.loopLimit) console.log('ini', arr, this.userBotDict[username].stepRecord) console.log( 'arr.reduce((x, y) => x * y) ', arr.reduce((x, y) => x * y) ) console.log( 'arr.reduce((x, y) => x * y) ', arr.reduce((x, y) => x * y) ) const lastIndex = this.userBotDict[username].stepRecord.length - 1 console.log('limit last', this.userBotDict[username].stepRecord.length, this.loopLimit) console.log('limit', this.userBotDict[username].stepRecord[this.userBotDict[username].stepRecord.length - 1] ** this.loopLimit) if (arr.reduce((x, y) => x * y) === this.userBotDict[username].stepRecord[this.userBotDict[username].stepRecord.length - 1] ** this.loopLimit) { this.userBotDict[username].step = 100 } } // 对话结束 if (this.userBotDict[username].step == 100) { this.userBotDict[username].paramsInit() this.userBotDict[username] = this.addReply(username, { type: 1, content: '你已经输入太多错误指令了,小图已经不知道怎么回答了,仍是从新发送照片吧' }) return this.userBotDict[username] } // 图片处理完毕后 if (this.userBotDict[username].step == 101) { this.userBotDict[username].paramsInit() this.userBotDict[username] = this.addReply(username, { type: 1, content: '你的图片已经生成了,若是还想体验的话,请从新发送照片' }) return this.userBotDict[username] } if (this.userBotDict[username].step == 0) { console.log('第一轮对话,让用户选择转换的内容') this.userBotDict[username].stepRecord.push(0) if (content.type === 3) { this.userBotDict[username].step += 1 this.userBotDict[username] = this.addReply(username, this.replyList[0]) return this.userBotDict[username] } else { if (this.config.tipsword && content.content.includes(this.config.tipsword)) { // 若是没有发图片,直接发文字,触发关键词 return { replys: [{ type: 1, content: '想要体验人脸卡通化功能,请先发送带人脸的照片给我' }], replys_index: 0, } } else { // 若是没有发图片,直接发文字,没有触发关键词 this.removeBot(username) return { replys: [{ type: 1, content: '' }], replys_index: 0, } } } } else if (this.userBotDict[username].step == 1) { console.log('第二轮对话,用户选择须要转换的模式') this.userBotDict[username].stepRecord.push(1) if (content.type === 1) { if (parseInt(content.content) === 1) { // 用户选择了漫画模式 this.userBotDict[username].step = 101 this.userBotDict[username].model = 1 return await this.generateImage(username) } else if (parseInt(content.content) === 2) { // 用户选择了变换年龄模式 this.userBotDict[username].step += 1 this.userBotDict[username].model = 2 this.userBotDict[username] = this.addReply(username, this.replyList[1]) return this.userBotDict[username] } else if (parseInt(content.content) === 3) { // 用户选择了变换性别模式 this.userBotDict[username].step += 1 this.userBotDict[username].model = 3 this.userBotDict[username] = this.addReply(username, this.replyList[2]) return this.userBotDict[username] } else { // 输入模式错误提示 this.userBotDict[username].step = 1 this.userBotDict[username] = this.addReply(username, this.replyList[3]) return this.userBotDict[username] } } } else if (this.userBotDict[username].step == 2) { console.log('第三轮对话,用户输入指定模式所须要的配置') this.userBotDict[username].stepRecord.push(2) if (content.type === 1) { if (this.userBotDict[username].model === 2) { // 用户选择了年龄变换模式 if (parseInt(content.content) >= 10 && parseInt(content.content) <= 80) { this.userBotDict[username].step = 101 this.userBotDict[username].age = content.content return await this.generateImage(username) } else { this.userBotDict[username].step = 2 this.userBotDict[username] = this.addReply(username, this.replyList[4]) return this.userBotDict[username] } } else if (this.userBotDict[username].model === 3) { // 用户选择了性别变换模式 if (parseInt(content.content) === 0 || parseInt(content.content) === 1) { this.userBotDict[username].step = 101 this.userBotDict[username].gender = parseInt(content.content) return await this.generateImage(username) } else { this.userBotDict[username].step = 2 this.userBotDict[username] = this.addReply(username, this.replyList[5]) return this.userBotDict[username] } } } } } addReply(username, replys) { this.userBotDict[username].replys.push(replys) this.userBotDict[username].replys_index = this.userBotDict[username].replys.length - 1 return this.userBotDict[username] } removeBot(dictKey) { console.log('bot process remove', dictKey) delete this.userTimeDict[dictKey] delete this.userBotDict[dictKey] } getBotList() { return this.userBotDict } /** * 生成图片 * @param {*} username 用户名 * @returns */ async generateImage(username) { const image = await generateCarton(this.config, this.userBotDict[username].imageData, { model: this.userBotDict[username].model, gender: this.userBotDict[username].gender, age: this.userBotDict[username].age }) this.userBotDict[username] = this.addReply(username, image) return this.userBotDict[username] } getImage(username, content, step) { this.userBotDict[username].paramsInit() this.userBotDict[username].step = step if (content.type === 3) { this.userBotDict[username].imageData = content.url } let replys = { type: 1, content: '请选择你要转换的模式(发送序号):\n\n [1]、卡通化照片\n\n[2]、变换年龄\n\n[3]、变换性别\n\n' } this.userBotDict[username] = this.addReply(username, replys) return this.userBotDict[username] } // 对话入口 async run(username, content) { if (content.type === 1) { if (!Object.keys(this.userTimeDict).includes(username)) { if (this.config.tipsword && content.content.includes(this.config.tipsword)) { // 若是没有发图片,直接发文字,触发关键词 return { replys: [{ type: 1, content: '想要体验人脸卡通化功能,请先发送带人脸的照片给我' }], replys_index: 0, } } else { // 若是没有发图片,直接发文字,没有触发关键词 return { replys: [{ type: 1, content: '' }], replys_index: 0, } } } else { // 若是对话环境中已存在,则更新对话内容 console.log(`${username}用户正在对话环境中`) return this.updateBot(username, content) } } else if (content.type === 3) { if (Object.keys(this.userTimeDict).includes(username)) { console.log(`${username}用户正在对话环境中`) return this.getImage(username, content, 1) } else { if (this.userBotDict.length > this.maxuser) { const minNum = Math.min(...Object.values(this.userTimeDict)) const earlyIndex = arr.indexOf(minNum) const earlyKey = Object.keys(this.userTimeDict)[earlyIndex] this.removeBot(earlyKey) } return await this.creatBot(username, content) } } } } module.exports = { BotManage, }
util/index.js
文件
roomSay和contactSay会把multiReply中返回的对话内容,“翻译”成真正发给用户的内容。例如:是文本的直接发送,是图片的包装一下发送给用户。
const { FileBox, UrlLink, MiniProgram } = require('wechaty') /** * 延时函数 * @param {*} ms 毫秒 */ async function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)) } /** * 群回复 * @param {*} contact * @param {*} msg * @param {*} isRoom * type 1 文字 2 图片url 3 图片base64 4 url连接 5 小程序 6 名片 */ async function roomSay(room, contact, msg) { try { if (msg.type === 1 && msg.content) { // 文字 console.log('回复内容', msg.content) contact ? await room.say(msg.content, contact) : await room.say(msg.content) } else if (msg.type === 2 && msg.url) { // url文件 let obj = FileBox.fromUrl(msg.url) console.log('回复内容', obj) contact ? await room.say('', contact) : '' await delay(500) await room.say(obj) } else if (msg.type === 3 && msg.url) { // bse64文件 let obj = FileBox.fromDataURL(msg.url, 'room-avatar.jpg') contact ? await room.say('', contact) : '' await delay(500) await room.say(obj) } else if (msg.type === 4 && msg.url && msg.title && msg.description) { console.log('in url') let url = new UrlLink({ description: msg.description, thumbnailUrl: msg.thumbUrl, title: msg.title, url: msg.url, }) console.log(url) await room.say(url) } else if (msg.type === 5 && msg.appid && msg.title && msg.pagePath && msg.description && msg.thumbUrl && msg.thumbKey) { let miniProgram = new MiniProgram({ appid: msg.appid, title: msg.title, pagePath: msg.pagePath, description: msg.description, thumbUrl: msg.thumbUrl, thumbKey: msg.thumbKey, }) await room.say(miniProgram) } } catch (e) { console.log('群回复错误', e) } } /** * 私聊发送消息 * @param contact * @param msg * @param isRoom * type 1 文字 2 图片url 3 图片base64 4 url连接 5 小程序 6 名片 */ async function contactSay(contact, msg, isRoom = false) { try { if (msg.type === 1 && msg.content) { // 文字 console.log('回复内容', msg.content) await contact.say(msg.content) } else if (msg.type === 2 && msg.url) { // url文件 let obj = FileBox.fromUrl(msg.url) console.log('回复内容', obj) if (isRoom) { await contact.say(`@${contact.name()}`) await delay(500) } await contact.say(obj) } else if (msg.type === 3 && msg.url) { // bse64文件 let obj = FileBox.fromDataURL(msg.url, 'user-avatar.jpg') await contact.say(obj) } else if (msg.type === 4 && msg.url && msg.title && msg.description && msg.thumbUrl) { let url = new UrlLink({ description: msg.description, thumbnailUrl: msg.thumbUrl, title: msg.title, url: msg.url, }) await contact.say(url) } else if (msg.type === 5 && msg.appid && msg.title && msg.pagePath && msg.description && msg.thumbUrl && msg.thumbKey) { let miniProgram = new MiniProgram({ appid: msg.appid, title: msg.title, pagePath: msg.pagePath, description: msg.description, thumbUrl: msg.thumbUrl, thumbKey: msg.thumbKey, }) await contact.say(miniProgram) } } catch (e) { console.log('私聊发送消息失败', msg, e) } } module.exports = { contactSay, roomSay, delay, }
要注意一下,不要把额度用超了,用超了就只能下个月才能玩了。
若有使用问题能够直接加小助手,回复卡通
,进微信群交流,若是
历史文章
本文由博客一文多发平台 OpenWrite 发布!