[
  {
    "path": ".gitignore",
    "content": "node_modules\ndownload\n*.out\n*.log\n"
  },
  {
    "path": "Dockerfile",
    "content": "FROM ubuntu:latest\n\nMAINTAINER huangruichang \"532079207@qq.com\"\n\nRUN apt-get upgrade && apt-get update -y\n\nEXPOSE 8233\n\n# basic tools\nRUN apt-get install git curl -y\n\n# running electron on linux headless\n# https://github.com/segmentio/nightmare/issues/224#issuecomment-141575361\nRUN apt-get install -y libgtk2.0-0 libgconf-2-4 \\\n    libasound2 libxtst6 libxss1 libnss3 xvfb\n\n# nodejs related\n# https://nodejs.org/en/download/package-manager/\nRUN curl -sL https://deb.nodesource.com/setup_7.x | bash\nRUN apt-get install -y nodejs\n\n# WXBOT HOME\nENV WXBOT_HOME /home/wxbot\n\n# clone wxbot\nRUN git clone https://github.com/fritx/wxbot.git $WXBOT_HOME\n\n# replace npm and electron download source\nRUN echo -e \"registry=https://registry.npm.taobao.org\\nelectron_mirror=https://npm.taobao.org/mirrors/electron/\" > ~/.npmrc\n\nRUN cd $WXBOT_HOME && npm install\n\n# install http-server\nRUN npm install http-server -g\n\n# Start wxbot\nCMD xvfb-run --server-args=\"-screen 0 1024x768x24\" node $WXBOT_HOME & http-server /tmp -p 8233 -s\n"
  },
  {
    "path": "README.md",
    "content": "# wxbot 微信机器人\n\n<a href=\"https://github.com/fritx/awesome-wechat\"><img width=\"110\" height=\"20\" src=\"https://img.shields.io/badge/awesome-wechat-brightgreen.svg\"></a>&nbsp;&nbsp;<a href=\"https://github.com/fritx/wxbot\"><img width=\"74\" height=\"20\" src=\"https://img.shields.io/badge/github-dev-orange.svg\"></a>\n\n- __普通个人号 微信机器人/外挂__ (不同于[webot](https://github.com/node-webot/webot)等公众号机器人)\n- 意义: 个人号可充当公众号使用 关系增强/门槛降低/更多行为/依旧自动化\n- 与[qqbot](https://github.com/xhan/qqbot)/[wqq](https://github.com/fritx/wqq)等不同: 基于浏览器/用户行为自动化 更贴近用户/更可靠\n- 基于浏览器桌面平台[electron](https://github.com/atom/electron) 跨平台win/linux/mac\n- 基于微信网页版 <https://wx.qq.com>\n- 目前处于高度开发和观察阶段\n- 目前代码提供自动回复 可自行定制\n\n请使用较新版本的electron>=v1.0\n如果electron=v0.x 可以查看分支[wxbot#electron-v0](https://github.com/fritx/wxbot/tree/electron-v0)\n\n```plain\n$ cd wxbot\n$ npm install\n$ node .  # 运行 需扫二维码登录\n```\n\n<img width=\"643\" src=\"https://raw.githubusercontent.com/fritx/wxbot/dev/screenshot.jpeg\">\n\n## 功能实现\n\n- [x] 自动回复\n- [x] 识别并回复相同的文本/表情/emoji\n- [x] 识别图片/语音/视频/小视频\n- [x] 识别位置/名片/链接/附件\n- [x] 识别转账/在线聊天/实时对讲\n- [x] 发送图片\n- [x] 下载自定义表情/名片/图片/语音/附件\n- [ ] 下载视频/小视频\n- [ ] 接受好友请求并回复\n- [ ] 感应系统消息 时间/邀请加群/红包等\n- [ ] loop错误超时解锁\n- [x] 探索运行于无界面平台 [atom/electron#228](https://github.com/atom/electron/issues/228)\n\n## 无界面linux运行\n\n- 从命令行输出 获取二维码图片url 自行打开/扫描\n- 参照配置 [segmentio/nightmare#224 (comment)](https://github.com/segmentio/nightmare/issues/224#issuecomment-141575361)\n\n## Docker 相关\n\n```plain\n$ cd wxbot\n$ docker build -t wxbot .\n$ docker run -d -p 8233:8233 # 浏览器访问 docker 的 8233 端口，即可获取图片\n```\n\n## 如何正确地下载electron\n\n参考 <http://blog.fritx.me/?weekly/150904>\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"wxbot\",\n  \"version\": \"0.1.0\",\n  \"description\": \"普通个人号 微信机器人/外挂\",\n  \"repository\": \"git@github.com:fritx/wxbot.git\",\n  \"homepage\": \"https://github.com/fritx/wxbot\",\n  \"keywords\": [\n    \"weixin\",\n    \"wx\",\n    \"qq\",\n    \"bot\"\n  ],\n  \"main\": \"src\",\n  \"scripts\": {\n    \"start\": \"node .\"\n  },\n  \"dependencies\": {\n    \"electron\": \"^1.6.2\",\n    \"mime\": \"^1.3.4\"\n  }\n}\n"
  },
  {
    "path": "src/index.js",
    "content": "let { spawn } = require('child_process')\nlet { join } = require('path')\nlet ipc = require('./ipc')\n\nlet electron = require('electron')\nlet runner = join(__dirname, 'runner.js')\nlet proc = spawn(electron, ['--js-flags=\"--harmony\"', runner], {\n  stdio: [null, null, null, 'ipc']\n})\n\nlet child = ipc(proc)\nchild.on('runner', (k, args) => {\n  console[k](`runner:${k}`, ...args)\n})\n\nprocess.on('exit', end)\nprocess.on('SIGINT', end)\nprocess.on('SIGTERM', end)\nprocess.on('SIGQUIT', end)\nprocess.on('SIGHUP', end)\nprocess.on('SIGBREAK', end)\n\nfunction end () {\n  if (proc.connected) proc.disconnect()\n  proc.kill()\n}\n"
  },
  {
    "path": "src/ipc.js",
    "content": "/* eslint-disable */\n// https://github.com/segmentio/nightmare/blob/master/lib%2Fipc.js\n\n/**\n * Module dependencies\n */\n\nvar Emitter = require('events').EventEmitter;\n// var sliced = require('sliced');\n\n/**\n * Export `ipc`\n */\n\nmodule.exports = ipc;\n\n/**\n * Initialize `ipc`\n */\n\nfunction ipc(process) {\n  var emitter = new Emitter();\n  var emit = emitter.emit;\n\n  // no parent\n  if (!process.send) {\n    return emitter;\n  }\n\n  process.on('message', function(data) {\n    // emit.apply(emitter, sliced(data));\n    emit.apply(emitter, [...data]);\n  });\n\n  emitter.emit = function() {\n    if(process.connected){\n      // process.send(sliced(arguments));\n      process.send(Array.from(arguments));\n    }\n  }\n\n  return emitter;\n}\n"
  },
  {
    "path": "src/parseMsg.js",
    "content": "function debug(...args){\n  var json = JSON.stringify(args)\n  console.log(json)\n}\n\nmodule.exports = parseMsg\n\n// msg = {\n//   from, room, style,\n//   type:\n//     not supported|not recognized\n//     text|picture|app|card|location|attach\n//     sticker|emoticon|transfer\n//     voice|video|microvideo|video/voice call\n//     real-time location|real-time voice\n//     ----\n//     red packet|recall\n//     new member|member is stranger\n//     real-time location ended|real-time voice ended\n//   text|title|desc|src|poster...\n// }\nfunction parseMsg ($msg) {\n  var msg = {}\n  var $message = $msg.closest('.message')\n  var $nickname = $message.find('.nickname')\n  var $titlename = $('.title_name')\n\n  if ($nickname.length) { // 群聊\n    var from = $nickname.text()\n    var room = $titlename.text()\n  } else { // 单聊\n    var from = $titlename.text()\n    var room = null\n  }\n  Object.assign(msg, {\n    from, room\n  })\n  debug('来自', from, room) // 这里的nickname会被remark覆盖\n\n  if ($msg.is('.message_system')) {\n    var ctn = $msg.find('.content').text()\n    debug('接收', '系统标记', ctn)\n    Object.assign(msg, {\n      style: 'system',\n      text: ctn\n    })\n\n    var mat\n    if (ctn === '收到红包，请在手机上查看' ||\n        ctn === 'Red packet received. View on phone.') {\n      // text = '发毛红包'\n      Object.assign(msg, {\n        type: 'red packet'\n      })\n    } else if (ctn === '位置共享已经结束' ||\n        ctn === 'Real-time Location session ended.') {\n      // text = '位置共享已经结束'\n      Object.assign(msg, {\n        type: 'real-time location ended'\n      })\n    } else if (ctn === '实时对讲已经结束') {\n      // text = '实时对讲已经结束'\n      Object.assign(msg, {\n        type: 'real-time voice ended'\n      })\n    } else if (mat = ctn.match(/\"(.+)\"邀请\"(.+)\"加入了群聊/)) {\n      // text = '加毛人'\n      Object.assign(msg, {\n        type: 'new member',\n        by: mat[1],\n        who: mat[2]\n      })\n    } else if (mat = ctn.match(/\"(.+)\"与群里其他人都不是微信朋友关系，请注意隐私安全/)) {\n      // text = '加毛人'\n      Object.assign(msg, {\n        type: 'member is stranger',\n        who: mat[1]\n      })\n    } else if (mat = ctn.match(/You were removed from the group chat by \"(.+)\"/)) {\n      Object.assign(msg, {\n        type: 'removed',\n        by: mat[1]\n      })\n    } else if (mat = ctn.match(/(.+)(撤回了一条消息| withdrew a message)/)) {\n      // text = '撤你妹'\n      Object.assign(msg, {\n        type: 'recall',\n        by: mat[1]\n      })\n    } else {\n      // 无视\n      Object.assign(msg, {\n        type: 'not recognized',\n        text: ctn\n      })\n    }\n  } else if ($msg.is('.emoticon')) { // 用户自定义表情\n    var src = $msg.find('.msg-img').prop('src')\n    debug('接收', 'emoticon', src)\n    // reply.text = '发毛表情'\n    Object.assign(msg, {\n      type: 'emoticon',\n      src\n    })\n  } else if ($msg.is('.picture')) {\n    var src = $msg.find('.msg-img').prop('src')\n    debug('接收', 'picture', src)\n    // reply.text = '发毛图片'\n    // reply.image = join(__dirname, '../fuck.jpeg')\n    Object.assign(msg, {\n      type: 'picture',\n      src\n    })\n  } else if ($msg.is('.location')) {\n    var src = $msg.find('.img').prop('src')\n    var desc = $msg.find('.desc').text()\n    debug('接收', 'location', desc)\n    // reply.text = desc\n    Object.assign(msg, {\n      type: 'location',\n      src, desc\n    })\n  } else if ($msg.is('.attach')) {\n    var title = $msg.find('.title').text()\n    var size = $msg.find('span:first').text()\n    var $download = $msg.find('a[download]') // 可触发下载\n    var src = $download.prop('href')\n    debug('接收', 'attach', title, size)\n    // reply.text = title + '\\n' + size\n    Object.assign(msg, {\n      type: 'attach',\n      title, size, src\n    })\n  } else if ($msg.is('.microvideo')) {\n    var poster = $msg.find('img').prop('src') // 限制\n    var src = $msg.find('video').prop('src') // 限制\n    debug('接收', 'microvideo', poster)\n    // reply.text = '发毛小视频'\n    Object.assign(msg, {\n      type: 'microvideo',\n      poster, src\n    })\n  } else if ($msg.is('.video')) {\n    var poster = $msg.find('.msg-img').prop('src') // 限制\n    debug('接收', 'video', poster)\n    // reply.text = '发毛视频'\n    Object.assign(msg, {\n      type: 'video',\n      poster\n    })\n  } else if ($msg.is('.voice')) {\n    $msg[0].click()\n    var duration = parseInt($msg.find('.duration').text())\n    var src = $('#jp_audio_1').prop('src') // 认证限制\n    debug('接收', 'voice', `${duration}s`, src)\n    // reply.text = '发毛语音'\n    Object.assign(msg, {\n      type: 'voice',\n      duration, src\n    })\n  } else if ($msg.is('.card')) {\n    var name = $msg.find('.display_name').text()\n    var wxid = $msg.find('.signature').text() // 微信注释掉了\n    var img = $msg.find('.img').prop('src') // 认证限制\n    debug('接收', 'card', name, wxid)\n    // reply.text = name + '\\n' + wxid\n    Object.assign(msg, {\n      type: 'card',\n      name, img\n    })\n  } else if ($msg.is('a.app')) {\n    var url = $msg.attr('href')\n    url = decodeURIComponent(url.match(/requrl=(.+?)&/)[1])\n    var title = $msg.find('.title').text()\n    var desc = $msg.find('.desc').text()\n    var img = $msg.find('.cover').prop('src') // 认证限制\n    debug('接收', 'link', title, desc, url)\n    // reply.text = title + '\\n' + url\n    Object.assign(msg, {\n      type: 'app',\n      url, title, desc, img\n    })\n  } else if ($msg.is('.plain')) {\n    var text = ''\n    var ctn = ''\n    var normal = false\n    var $text = $msg.find('.js_message_plain')\n    $text.contents().each(function(i, node){\n      if (node.nodeType === Node.TEXT_NODE) {\n        ctn += node.nodeValue\n      } else if (node.nodeType === Node.ELEMENT_NODE) {\n        var $el = $(node)\n        if ($el.is('br')) ctn += '\\n'\n        else if ($el.is('.qqemoji, .emoji')) {\n          ctn += $el.attr('text').replace(/_web$/, '')\n        }\n      }\n    })\n    if (ctn === '[收到了一个表情，请在手机上查看]' ||\n        ctn === '[Send an emoji, view it on mobile]' ||\n        ctn === '[Received a sticker. View on phone]') { // 微信表情包\n      // text = '发毛表情'\n      Object.assign(msg, {\n        type: 'sticker' // 微信内部表情\n      })\n    } else if (ctn === '[收到一条微信转账消息，请在手机上查看]' ||\n        ctn === '[Received a micro-message transfer message, please view on the phone]' ||\n        ctn === '[Received transfer. View on phone.]') {\n      // text = '转毛帐'\n      Object.assign(msg, {\n        type: 'transfer'\n      })\n    } else if (ctn === '[收到一条视频/语音聊天消息，请在手机上查看]' ||\n        ctn === '[Receive a video / voice chat message, view it on your phone]' ||\n        ctn === '[Received video/voice chat message. View on phone.]') {\n      // text = '聊jj'\n      Object.assign(msg, {\n        type: 'video/voice call'\n      })\n    } else if (ctn === '我发起了实时对讲') {\n      // text = '对讲你妹'\n      Object.assign(msg, {\n        type: 'real-time voice'\n      })\n    } else if (ctn === '该类型暂不支持，请在手机上查看' ||\n        ctn === '[收到一条网页版微信暂不支持的消息类型，请在手机上查看]') {\n      // text = '不懂'\n      Object.assign(msg, {\n        type: 'not supported'\n      })\n    } else if (ctn.match(/(.+)发起了位置共享，请在手机上查看/) ||\n        ctn.match(/(.+)Initiated location sharing, please check on the phone/) ||\n        ctn.match(/(.+)started a real\\-time location session\\. View on phone/)) {\n      // text = '发毛位置共享'\n      Object.assign(msg, {\n        type: 'real-time location'\n      })\n    } else {\n      normal = true\n      // text = ctn\n      Object.assign(msg, {\n        type: 'text',\n        text: ctn\n      })\n    }\n    debug('接收', 'text', ctn)\n    // if (normal && !text.match(/叼|屌|diao|丢你|碉堡/i)) text = ''\n    // reply.text = text\n  } else {\n    console.log('未成功解析消息', $msg.html())\n    Object.assign(msg, {\n      type: 'not recognized'\n    })\n  }\n\n  return msg\n}\n"
  },
  {
    "path": "src/preload.js",
    "content": "require('./preloadIpc')\nlet { clipboard, nativeImage } = require('electron')\nlet { s, sa, delay, download } = require('./util')\nlet parseMsg = require('./parseMsg')\nlet replyMsg = require('./replyMsg')\n\n// 禁用微信网页绑定的beforeunload\n// 导致页面无法正常刷新和关闭\nwindow.__defineSetter__('onbeforeunload', () => {\n  // noop\n})\n\ndocument.addEventListener('DOMContentLoaded', () => {\n  // 禁止外层网页滚动 影响使用\n  document.body.style.overflow = 'hidden'\n\n  detectPage()\n})\n\nasync function autoReply () {\n  while (true) { // 保持回复消息\n    try {\n      let msg = await detectMsg()\n      console.log('解析得到msg', JSON.stringify(msg))\n\n      let reply = await replyMsg(msg)\n      console.log('reply', JSON.stringify(reply))\n\n      if (reply) {\n        // continue // test: 不作回复\n        pasteMsg(reply)\n        await clickSend(reply)\n      }\n    } catch (err) {\n      console.error('自动回复出现err', err)\n    }\n  }\n}\n\nasync function detectMsg () {\n  // 重置回\"文件传输助手\" 以能接收未读红点\n  s('img[src*=filehelper]').closest('.chat_item').click()\n\n  let reddot\n  while (true) {\n    await delay(100)\n    reddot = s('.web_wechat_reddot, .web_wechat_reddot_middle')\n    if (reddot) break\n  }\n\n  let item = reddot.closest('.chat_item')\n  item.click()\n\n  await delay(100)\n  let $msg = $([\n    '.message:not(.me) .bubble_cont > div',\n    '.message:not(.me) .bubble_cont > a.app',\n    '.message:not(.me) .emoticon',\n    '.message_system'\n  ].join(', ')).last()\n\n  let msg = parseMsg($msg)\n  return msg\n}\n\nasync function clickSend (opt) {\n  if (opt.text) {\n    s('.btn_send').click()\n  } else if (opt.image) {\n    // fixme: 超时处理\n    while (true) {\n      await delay(300)\n      let btn = s('.dialog_ft .btn_primary')\n      if (btn) {\n        btn.click() // 持续点击\n      } else {\n        return\n      }\n    }\n  }\n}\n\n// 借用clipboard 实现输入文字 更新ng-model=EditAreaCtn\nfunction pasteMsg (opt) {\n  let oldImage = clipboard.readImage()\n  let oldHtml = clipboard.readHtml()\n  let oldText = clipboard.readText()\n\n  clipboard.clear() // 必须清空\n  if (opt.image) {\n    // 不知为啥 linux上 clipboard+nativeimage无效\n    try {\n      clipboard.writeImage(nativeImage.createFromPath(opt.image))\n    } catch (err) {\n      opt.image = null\n      opt.text = '妈蛋 发不出图片'\n    }\n  }\n  if (opt.html) clipboard.writeHtml(opt.html)\n  if (opt.text) clipboard.writeText(opt.text)\n  s('#editArea').focus()\n  document.execCommand('paste')\n\n  clipboard.writeImage(oldImage)\n  clipboard.writeHtml(oldHtml)\n  clipboard.writeText(oldText)\n}\n\nfunction detectPage () {\n  let ps = [\n    detectCache(), // 协助跳转\n    detectLogin(),\n    detectChat()\n  ]\n\n  // 同时判断login和chat 判断完成则同时释放\n  Promise.race(ps)\n    .then(data => {\n      ps.forEach(p => p.cancel())\n\n      let { page, qrcode } = data\n      console.log(`目前处于${page}页面`)\n\n      if (page === 'login') {\n        download(qrcode)\n      } else if (page === 'chat') {\n        autoReply()\n      }\n    })\n}\n\n// 需要定制promise 提供cancel方法\nfunction detectChat () {\n  let toCancel = false\n\n  let p = (async () => {\n    while (true) {\n      if (toCancel) return\n      await delay(300)\n\n      let item = s('.chat_item')\n      if (item) {\n        return { page: 'chat' }\n      }\n    }\n  })()\n\n  p.cancel = () => {\n    toCancel = true\n  }\n  return p\n}\n\n// 需要定制promise 提供cancel方法\nfunction detectLogin () {\n  let toCancel = false\n\n  let p = (async () => {\n    while (true) {\n      if (toCancel) return\n      await delay(300)\n\n      // 共有两次load事件 仅处理后一次\n      // 第1次src https://res.wx.qq.com/a/wx_fed/webwx/res/static/img/2z6meE1.gif\n      // 第2次src https://login.weixin.qq.com/qrcode/IbAG40QD6A==\n      let img = s('.qrcode img')\n      if (img && img.src.endsWith('==')) {\n        return {\n          page: 'login',\n          qrcode: img.src\n        }\n      }\n    }\n  })()\n\n  p.cancel = () => {\n    toCancel = true\n  }\n  return p\n}\n\n// 需要定制promise 提供cancel方法\n// 可能跳到缓存了退出登陆用户头像的界面，手动点一下切换用户，以触发二维码下载\nfunction detectCache () {\n  let toCancel = false\n\n  let p = (async () => {\n    while (true) {\n      if (toCancel) return\n      await delay(300)\n\n      let btn = s('.association .button_default')\n      if (btn) btn.click() // 持续点击\n    }\n  })()\n\n  p.cancel = () => {\n    toCancel = true\n  }\n  return p\n}\n"
  },
  {
    "path": "src/preloadIpc.js",
    "content": "let { ipcRenderer } = require('electron')\n\n;['log', 'info', 'warn', 'error'].forEach(k => {\n  let fn = console[k].bind(console)\n  console[k] = (...args) => {\n    fn(...args)\n    ipcRenderer.send('renderer', k, args)\n  }\n})\n"
  },
  {
    "path": "src/replyMsg.js",
    "content": "let { download } = require('./util')\nlet { join } = require('path')\n\nmodule.exports = replyMsg\n\nlet handlers = {\n  'text' ({ text }) {\n    return { text }\n  },\n\n  'picture' ({ src }) {\n    saveMedia(src)\n\n    let largeSrc = src.replace('&type=slave', '')\n    saveMedia(largeSrc, 'large') // 大图\n    return {\n      image: join(__dirname, '../fuck.jpeg')\n    }\n  },\n\n  'emoticon' () { // 用户自定义表情\n    return { text: '发毛表情' }\n  },\n\n  'sticker' () { // 微信内部表情\n    return { text: '发毛表情' }\n  },\n\n  'voice' ({ src }) {\n    saveMedia(src)\n    return { text: '发毛语音' }\n  },\n\n  'video' () {\n    return { text: '发毛视频' }\n  },\n\n  'microvideo' () {\n    return { text: '发毛小视频' }\n  },\n\n  'location' ({ src, desc }) {\n    saveMedia(src)\n    return { text: desc }\n  },\n\n  'attach' ({ src, title, size }) {\n    saveMedia(src)\n    return {\n      text: title + '\\n' + size\n    }\n  },\n\n  'app' ({ title, url }) {\n    return {\n      text: title + '\\n' + url\n    }\n  },\n\n  'card' ({ name, wxid }) {\n    return { text: name }\n  },\n\n  'transfer' () {\n    return { text: '转毛帐' }\n  },\n\n  'video/voice call' () {\n    return { text: '聊jj' }\n  },\n\n  // 似乎功能已经不支持\n  // 'real-time voice' () {\n  //   return { text: '对讲你妹' }\n  // },\n\n  /* 以下为无法自动感应病回应的消息 */\n  'red packet' () {\n    return { text: '发毛红包' }\n  },\n\n  'recall' ({ by }) {\n    // return { text: `${by} 撤回了消息` }\n    return { text: '撤jj' }\n  },\n\n  'new member' ({ who, by }) {\n    // return { text: `${by} 邀请了 ${who}` }\n    return { text: '加毛人' }\n  },\n\n  'member is stranger' ({ who }) {\n    return { text: `大家要小心 ${who}` }\n  },\n\n  'real-time location ended' () {\n    return { text: '位置共享已经结束' }\n  },\n\n  // 似乎功能已经不支持\n  // 'real-time voice ended' () {\n  //   return { text: '实时对讲已经结束' }\n  // },\n\n  'removed' () {\n    return null\n  },\n\n  'not recognized' () {\n    return null // 忽略消息 不回应\n  },\n  /* 以上为无法自动感应病回应的消息 */\n\n  'not supported' () {\n    return { text: '不懂' }\n  }\n}\n\n// fixme: attach文件附带content-deposition 覆盖download属性设置的filename\n// https://stackoverflow.com/questions/23872902/chrome-download-attribute-not-working\nfunction saveMedia (src, suffix) {\n  let mat = src.match(/msgid=(\\d+)/i)\n  let msgid = mat && mat[1]\n  let filename = msgid || ''\n  if (suffix) filename += '_' + suffix\n  download(src, filename)\n}\n\nasync function replyMsg (msg) {\n  let handler = handlers[msg.type]\n    || handlers['not recognized']\n\n  let reply = await toPromise(\n    handler(msg)\n  )\n  return reply\n}\n\nfunction toPromise (ret) {\n  if (ret && ret.then) return ret\n  return Promise.resolve(ret)\n}\n"
  },
  {
    "path": "src/runner.js",
    "content": "require('./runnerIpc')\nlet { app, session, ipcMain, BrowserWindow } = require('electron')\nlet { tmpdir } = require('os')\nlet { join } = require('path')\n// let open = require('open')\nlet mime = require('mime')\n\nlet downloadDir = join(__dirname, '../download')\ntry {\n  fs.mkdirSync(downloadDir)\n} catch (err) {\n  // ignore\n}\n\nlet win\n\n// 将renderer的输出 转发到terminal\nipcMain.on('renderer', (e, k, args) => {\n  console[k]('renderer', k, args)\n})\n\napp.on('activate', () => {\n  if (win) win.show()\n})\n\napp.on('ready', () => {\n  let show = true // 是否显示浏览器窗口\n  let preload = join(__dirname, 'preload.js')\n\n  win = new BrowserWindow({\n    webPreferences: {\n      preload,\n      nodeIntegration: false\n    },\n    width: 900,\n    height: 610,\n    show\n  })\n\n  // Ctrl+C只会发送win.close 并且如果已登录  窗口还关不掉\n  // 所以干脆改为窗口关闭 直接退出\n  // https://github.com/electron/electron/issues/5273\n  win.on('close', e => {\n    e.preventDefault()\n    win.destroy()\n  })\n\n  win.once('ready-to-show', () => {\n    win.show()\n  })\n  win.loadURL('https://wx.qq.com')\n\n  let sess = session.defaultSession\n  sess.on('will-download', async (e, item) => {\n    let url = item.getURL()\n\n    if (/\\/qrcode\\/.+==/.test(url)) { // 登录二维码\n      let dest = join(tmpdir(), `qrcode_${Date.now()}.jpg`)\n      let state = await saveItem(item, dest, '二维码保存')\n\n      // todo: 如果是运行在无界面环境 则需要将二维码通过url展示出来\n      // 如果不显示浏览器窗口 则调用程序单独打开二维码\n      // if (!show && state === 'completed') {\n      //   open(dest, err => {\n      //     if (err) {\n      //       console.error('二维码打开 err:', err)\n      //     }\n      //   })\n      // }\n    }\n    else { // 下载消息中的多媒体文件 图片/语音\n      let mimeType = item.getMimeType()\n      let filename = item.getFilename()\n      let ext = mime.extension(mimeType)\n\n      // 修复mime缺少映射关系: `audio/mp3` => `mp3`\n      if (mimeType === 'audio/mp3') ext = 'mp3'\n      if (ext === 'bin') ext = ''\n      if (ext) filename += '.' + ext\n      \n      let date = new Date().toJSON()\n      filename = date + '_' + filename\n\n      // 跨平台文件名容错\n      // http://blog.fritx.me/?weekly/160227\n      filename = filename.replace(/[\\\\\\/:\\*\\,\"\\?<>|]/g, '_')\n\n      let dest = join(downloadDir, filename)\n      await saveItem(item, dest, `文件保存 ${filename}`)\n    }\n  })\n})\n\nasync function saveItem (item, dest, log) {\n  item.setSavePath(dest)\n  return await new Promise(rs => {\n    item.on('done', (e, state) => {\n      console.log(`${log} state:${state}`)\n      rs(state)\n    })\n  })\n}\n"
  },
  {
    "path": "src/runnerIpc.js",
    "content": "let parent = require('./ipc')(process)\n\n;['log', 'info', 'warn', 'error'].forEach(k => {\n  let fn = console[k].bind(console)\n  console[k] = (...args) => {\n    fn(...args)\n    parent.emit('runner', k, args)\n  }\n})\n"
  },
  {
    "path": "src/util.js",
    "content": "exports.s = s\nexports.sa = sa\nexports.download = download\nexports.delay = delay\n\nasync function delay (duration) {\n  return new Promise(rs => {\n    setTimeout(() => rs(), duration)\n  })\n}\n\nfunction download (href, filename = '') {\n  console.log('触发下载', filename, href)\n  let a = document.createElement('a')\n  a.download = filename\n  a.href = href\n  a.click()\n}\n\nfunction s (selector) {\n  return document.querySelector(selector)\n}\nfunction sa (selector) {\n  return document.querySelectorAll(selector)\n}\n"
  }
]