Tanabata is coming, and to everyone program ape to his girlfriend, wife to send a gift of the festival. This year, the wife stipulated, can not spend too much money, also prohibited to buy taobao straight man gifts. Really too difficult 😿, want to break scalp also do not know to send what good, the hair has dropped however wisp again wisp, what code bloom fireworks, photo wall, coax the robot of the wife had done. This time how to do, and do not let money, and have ideas, it seems that I can only sacrifice the big kill, code code over Tanabata. He likes to play Tiktok before he sees his wife. Also often used face cartoon, face age change, face gender change effects. Then I thought, why not make a wechat robot, you send photos I help you automatically generate special effects, without any APP can achieve, but also let the wife pull bestie to build a wechat group to play together.

Before open when you’re ready, wrote a “three steps to teach you the use of the Node in a WeChat coax girlfriend (gay friends) artifact”, so this time to write a robot is not too hard, just want to find good in advance corresponding image generated interface, after a search, found a personal face tencent cloud transform function, after test, found that is what I want, And the effect is good, the key is that there are 1,000 free per month, which is very good. Three kinds of conversion mode is 3000 times, white piao not fragrant 😏, white piao Tencent this is more fragrant, haha

Function is introduced

The main function of this implementation is to send photos and generate the corresponding effects according to the selection. The main implementation of wechat robot is Wechaty, and the protocol is based on the free Web protocol, so you don’t have to worry about the paid token of Wechaty. If your wechat cannot log in the web version of wechat, it doesn’t matter. Wechaty-puppet-wechat protocol is based on the UOS desktop version. A new account can also be used.

Realized functions:

Private chat and group can achieve photo effects

  • Multiple rounds of interactive dialogue implementation
    • Face photo animation
    • Face age change
    • Face sex conversion

Results show

Prepare a Tencent Cloud account in advance

Enable the photo conversion function

Login Tencent Cloud account, no directly QQ login, directly click the management console to open, do not have to pay, also do not need to choose the resource package, after opening automatically have a free quota of 1000 times per month, if you play with friends is completely enough. If you want to activate the community or the rich, just top up

Get Tencent’s Secretid and SecretKey

To access this page console.cloud.tencent.com/cam/capi get your secretid and secretkey, need to configure the plug-in

Using the step

1. Initialize the project

Node environment needs to be configured, node>=14,. Create a new folder called Face-carton and run NPM init inside the folder, pressing enter all the way

2. Install the avatar conversion plug-in and Wechaty

Wechaty-face-carton is the main feature I’m working on this time. It’s open source on Github. Since it’s already released to NPM, you just need to install it. If you want to know how the code works, you can go to the Github repository and check out the source code. For the implementation of the source code, I will also put a part of the core code to explain.

Configure NPM source as Taobao source (important, because you need to install Chromium, do not configure the download will fail or slow, because this thing about 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
Copy the code

If there is a problem with the installation, you are advised to delete node_modules and try it several times. For other environment problems, refer to frequently Asked questions and wechaty website

3. Main code (no more than 20 lines)

Create the index.js file in the directory

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.// The maximum number of conversations supported is not recommended. Otherwise, the memory usage will increase
      secretId: 'tencent secretId'./ / tencent secretId
      secretKey: 'tencent secretKey'./ / tencent secretKey
      allowUser: ['Leo_chen'].// Which friends are allowed to use the portrait caricature function, empty [] means to enable all people
      allowRoom: [Test '1'].// Which groups are allowed to use portrait caricature. Empty [] means that no group is enabled
      quickModel: true.// Quick Experience mode is disabled by default. After it is enabled, you can directly generate qr code scanning experience. If your code has login logic, you can not configure this item
      tipsword: 'cartoon'.If you send a picture directly, the picture cartoon function will be entered by default. If you do not fill in, no processing will be done when the user sends a text message for the first time
    })
  )
  .start()
  .catch((e) = > console.error(e))
Copy the code

Parameters that

Parameter names mandatory The default value instructions
maxuser no 20 The maximum number of conversations supported is not recommended. Otherwise, the memory usage will increase
secretId: is ‘ ‘ Tencent secretId
secretKey is ‘ ‘ Tencent secretKey
allowUser no [] Which friends are allowed to use the portrait caricature function, empty [] means that all people open
allowRoom no [] Which groups are allowed to use portrait caricature. Empty [] means that no group is enabled
quickModel no false Quick experience mode is disabled by default. After it is enabled, you can directly generate qr code scanning experience. If your code has login logic, you do not need to configure this item
tipsword no ‘cartoon’ Private chat to send messages, triggering photo cartoon prompt. If you send a picture directly, the picture cartoon function will be entered by default. If you do not fill in, no processing will be done when the user sends a text message for the first time. It is recommended to fill in the triggering keyword

4. Run the project

node index.js
Copy the code

Scan the code to log in, send pictures to the little assistant, the picture can be converted, for the picture cannot be converted, the little assistant will give the reason

Docker run

1, create Dockerfile

If you are experiencing a lot of environmental problems, you can also create a new Dockerfile in the root directory after the third step above and fill it with the contents. Yes! Just one line!

FROM wechaty/onbuild
Copy the code

2. Build the image

Once you’re done, you can build the image directly

docker build -t wechaty-carton .
Copy the code

3. Run the image

After the build is complete, you can scan the code directly after the run

docker run wechaty-carton
Copy the code

Plug-in core code parsing

Plug-in source address: github.com/leochen-g/w… If it can help you to coax your girlfriend happy, please give a star, careful heart ❤ to you 😏

The code structure

The main entry of the plugin is index.js, service/ Tencent. Js is the main method to invoke Tencent cloud services, service/multiReply. Public method extraction for private chat messages.

News listening

Wechaty exposes message events and simply filters them by message type. For this plugin, image messages are the key to triggering transformation


const { contactSay, roomSay, delay } = require('./util/index')
const { BotManage } = require('./service/multiReply')
const Qrterminal = require('qrcode-terminal')
let config = {}
let BotRes = ' '

/** * Filter private chat message events by message type *@param {*} That bot instance *@param {*} MSG Message body */
async function dispatchFriendFilterByMsgType(that, msg) {
  try {
    const type = msg.type()
    const contact = msg.talker() // The sender
    const name = await contact.name()
    const isOfficial = contact.type() === that.Contact.Type.Official
    const id = await contact.id
    switch (type) {
       // Text message processing
      case that.Message.Type.Text:
        content = msg.text()
        if(! isOfficial) {console.log('Sender${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
       // Image message processing
      case that.Message.Type.Image:
        console.log('Sender${name}: Posted a picture)
        // Check whether the specified person is configured to enable the conversion
        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('Not on${name}Face caricature function, or check whether the person has been configured wechat nickname ')}break
      default:
        break}}catch (error) {
    console.log('Listening message error', error)
  }
}

/** * Filter group message events by message type *@param {*} That bot instance *@param {*} Room Room object *@param {*} MSG Message body */
async function dispatchRoomFilterByMsgType(that, room, msg) {
  const contact = msg.talker() // The sender
  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) {
     // Text message processing
    case that.Message.Type.Text:
      content = msg.text()
      console.log(` group name:${roomName}Sender:${contactName}Content:${content}`)
      // Check whether the conversion is enabled for the specified group
      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
     // Image message processing
    case that.Message.Type.Image:
      console.log(` group name:${roomName}Sender:${contactName}Posted a picture)
      // Check whether the conversion is enabled for the specified group
      if (config.allowRoom.includes(roomName)) {
        console.log('Match to group:${roomName}Face caricature function has been opened, is being generated... `)
        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('This group of face caricatures is not enabled')}break
    default:
      break}}/** * Message event listener *@param {*} msg
 * @returns* /
async function onMessage(msg) {
  try {
      
    if(! BotRes) { BotRes =new BotManage(config.maxuser, this, config)
    }
    const room = msg.room() // Whether it is a group message
    const msgSelf = msg.self() // Whether the message is sent by yourself
    if (msgSelf) return
    // Distribute messages according to different message types
    if (room) {
      dispatchRoomFilterByMsgType(this, room, msg)
    } else {
      dispatchFriendFilterByMsgType(this, msg)
    }
  } catch (e) {
    console.log('reply error', e)
  }
}

.....
Copy the code

Multiple rounds of dialogue core code

For the implementation of multi-round dialogue, I refer to @kevinfu1717’s Python Wechaty code, and convert the core code of multi-round dialogue in his Python code into JS version. As for the specific implementation logic, I refer to his explanation, and I modify some method names corresponding to js version. Those interested in implementing Python can visit github.com/kevinfu1717…

Service/multiReply. Js file

  1. MultiReply within multiReply uses something like “simple factory mode”. (Bobbins familiar with the factory pattern can ignore this paragraph). A user_Bot is generated for each user who triggers the chat, and the user’s input is processed like raw material in a factory by workers assigned by BotManage to each process (skill modules such as cartoon face generation, face age change, face gender change, etc.) before the final product is assembled to the user. The input of different users is like raw materials that are constantly sent to the factory for processing, flowing bot to BotManage, and each user_Bot loads the entire conversation for the entire chat. The above is purely personal bs, factory model formal explanation specific see: juejin.cn/post/684490…
const { generateCarton } = require('./tencent')

class MultiReply {
  constructor() {
    this.userName = ' '
    this.startTime = 0 // Start time
    this.queryList = [] // What the user says
    this.replys = [] // For each reply, reply the user's content (list)
    this.reply_index = 0 // Reply to the user's words to reply to the part
    this.step = 0 / / the current step
    this.stepRecord = [] // Experienced step
    this.lastReply = {} // The content of the final reply
    this.imageData = ' ' // The image sent by the user
    this.model = 1 // Select comic mode by default
    this.age = 60 // The age selected by the user
    this.gender = 0 // The mode of user gender conversion
  }
  paramsInit() {
    this.startTime = 0 // Start time
    this.queryList = [] // What the user says
    this.replys = [] // For each reply, reply the user's content (list)
    this.reply_index = 0 // Reply to the user's words to reply to the part
    this.step = 0 / / the current step
    this.stepRecord = [] // Experienced step
    this.lastReply = {} // The content of the final reply
    this.imageData = ' ' // The image sent by the user
    this.model = 1 // Select comic mode by default
    this.age = 60 // The age selected by the user
    this.gender = 0 // The mode of user gender conversion}}class BotManage {
  constructor(maxuser, that, config) {
    this.Bot = that
    this.config = config
    this.userBotDict = {} // The user that holds all the conversations
    this.userTimeDict = {}
    this.maxuser = maxuser // Maximum number of concurrent users
    this.loopLimit = 4
    this.replyList = [
      { type: 1.content: 'please select you want to convert the model (send serial number) : \ n \ n [1], the cartoon pictures \ n \ n [2], transformation age \ n \ n [3], transform gender \ n \ n' },
      { type: 1.content: 'Please enter the age you wish to convert: please enter any number from 10 to 80.' },
      { type: 1.content: 'Please enter the gender you want to change (send serial number) : \n\n[0], male to female \n\n[1], female to male \n\n' },
      { type: 1.content: 'The number you entered is wrong, please enter the correct number.' },
      { type: 1.content: 'The age you entered is wrong, please enter any number from 10 to 80.' },
      { type: 1.content: 'You chose the wrong number, please enter the gender you want to change (send the number) : \n\n[0], male to female \n\n[1], female to male \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)
  }
  // Update the dialog
  async updateBot(username, content) {
    console.log(Update {`${username}} dialogue `)
    this.userTimeDict[username] = new Date().getTime()
    this.userBotDict[username].queryList.push(content)
    return await this.talk(username, content)
  }
  async talk(username, content) {
    // Prevent entering an infinite loop
    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}}// End of conversation
    if (this.userBotDict[username].step == 100) {
      this.userBotDict[username].paramsInit()
      this.userBotDict[username] = this.addReply(username, { type: 1.content: 'You've entered too many wrong instructions, the picture doesn't know how to answer, so let's resend the picture.' })
      return this.userBotDict[username]
    }
    // After the image is processed
    if (this.userBotDict[username].step == 101) {
      this.userBotDict[username].paramsInit()
      this.userBotDict[username] = this.addReply(username, { type: 1.content: 'Your picture has been generated, please resend it if you want to play with it.' })
      return this.userBotDict[username]
    }
    if (this.userBotDict[username].step == 0) {
      console.log('First round of conversation, let the user select what to convert.')
      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)) {
          // If you don't send pictures, send text directly and trigger keywords
          return {
            replys: [{ type: 1.content: 'If you want to experience cartoonish features of your face, please send me a photo of your face first.'}].replys_index: 0,}}else {
          // If there is no picture, send text directly, no trigger keywords
          this.removeBot(username)
          return {
            replys: [{ type: 1.content: ' '}].replys_index: 0,}}}}else if (this.userBotDict[username].step == 1) {
      console.log('In the second round of conversation, the user selects the mode to be converted.')
      this.userBotDict[username].stepRecord.push(1)
      if (content.type === 1) {
        if (parseInt(content.content) === 1) {
          // The user selects comic mode
          this.userBotDict[username].step = 101
          this.userBotDict[username].model = 1
          return await this.generateImage(username)
        } else if (parseInt(content.content) === 2) {
          // The user chose to change the age mode
          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) {
          // The user chose to change the gender pattern
          this.userBotDict[username].step += 1
          this.userBotDict[username].model = 3
          this.userBotDict[username] = this.addReply(username, this.replyList[2])
          return this.userBotDict[username]
        } else {
          // Input mode error
          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('Round 3, user enters the configuration required for the specified mode')
      this.userBotDict[username].stepRecord.push(2)
      if (content.type === 1) {
        if (this.userBotDict[username].model === 2) {
          // The user selects the age shift mode
          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) {
          // The user has selected the gender change mode
          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
  }
  /** * generate image *@param {*} Username indicates the 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: 'Please select the mode you want to change (send serial number) : \n\n[1], cartoon photo \n\n[2], change age \n\n[3], change gender \n\n' }
    this.userBotDict[username] = this.addReply(username, replys)
    return this.userBotDict[username]
  }
  // Dialog entry
  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)) {
          // If you don't send pictures, send text directly and trigger keywords
          return {
            replys: [{ type: 1.content: 'If you want to experience cartoonish features of your face, please send me a photo of your face first.'}].replys_index: 0,}}else {
          // If there is no picture, send text directly, no trigger keywords
          return {
            replys: [{ type: 1.content: ' '}].replys_index: 0,}}}else {
        // Update the dialog if it already exists
        console.log(`${username}The user is in a dialog)
        return this.updateBot(username, content)
      }
    } else if (content.type === 3) {
      if (Object.keys(this.userTimeDict).includes(username)) {
        console.log(`${username}The user is in a dialog)
        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,
}
Copy the code

Util/index. Js file

RoomSay and contactSay “translate” the conversation returned in multiReply into actual content sent to the user. For example, text is sent directly, and pictures are packaged and sent to users.

const { FileBox, UrlLink, MiniProgram } = require('wechaty')

/** * delay function *@param {*} Ms/ms *
async function delay(ms) {
  return new Promise((resolve) = > setTimeout(resolve, ms))
}

/** * group reply *@param {*} contact
 * @param {*} msg
 * @param {*} isRoom* Type 1 text 2 picture URL 3 picture base64 4 URL link 5 applet 6 business card */
async function roomSay(room, contact, msg) {
  try {
    if (msg.type === 1 && msg.content) {
      / / text
      console.log('Reply content', msg.content)
      contact ? await room.say(msg.content, contact) : await room.say(msg.content)
    } else if (msg.type === 2 && msg.url) {
      / / url files
      let obj = FileBox.fromUrl(msg.url)
      console.log('Reply content', obj)
      contact ? await room.say(' ', contact) : ' '
      await delay(500)
      await room.say(obj)
    } else if (msg.type === 3 && msg.url) {
      / / bse64 file
      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('Group reply error', e)
  }
}

/** * Private chat send messages *@param contact
 * @param msg
 * @param isRoom* Type 1 text 2 picture URL 3 picture base64 4 URL link 5 applet 6 business card */
async function contactSay(contact, msg, isRoom = false) {
  try {
    if (msg.type === 1 && msg.content) {
      / / text
      console.log('Reply content', msg.content)
      await contact.say(msg.content)
    } else if (msg.type === 2 && msg.url) {
      / / url files
      let obj = FileBox.fromUrl(msg.url)
      console.log('Reply content', obj)
      if (isRoom) {
        await contact.say(` @${contact.name()}`)
        await delay(500)}await contact.say(obj)
    } else if (msg.type === 3 && msg.url) {
      / / bse64 file
      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('Failed to send message in private chat', msg, e)
  }
}

module.exports = {
  contactSay,
  roomSay,
  delay,
}
Copy the code

Pay attention to

Be careful not to overdo it. If you overdo it, you can only play the next month.

Problems and Communication

If you have any problems, please put forward in issues, and I will reply in time

Article history

  • Wechaty- Web – Panel visualization plug-in
  • Three steps teach you to use Node to make a wechat unsingle magic tool, small white can start
  • Use UOS wechat desktop protocol to log in, wechaty free Web protocol can be used again