七夕將至,又到了各位程序猿給女朋友,老婆送禮物的節日。今年老婆規定了,不能花費太多錢,還禁止買淘寶直男禮物。真的太難了😿,想破頭皮也不知道送啥好,頭髮卻已經掉了一縷又一縷,什麼代碼綻開煙花,照片牆,哄老婆的機器人都作過了。此次怎麼辦呢,又不讓花錢,又要有想法,看來只能祭起個人大殺器,碼代碼過七夕了。看到老婆以前喜歡玩抖音,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 發佈!