firer.ts 15.7 KB
Newer Older
1
/**
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
2
 *   Wechaty - https://github.com/chatie/wechaty
3
 *
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
4
 *   @copyright 2016-2018 Huan LI <zixia@zixia.net>
5
 *
6 7 8
 *   Licensed under the Apache License, Version 2.0 (the "License");
 *   you may not use this file except in compliance with the License.
 *   You may obtain a copy of the License at
9
 *
10
 *       http://www.apache.org/licenses/LICENSE-2.0
11
 *
12 13 14 15 16
 *   Unless required by applicable law or agreed to in writing, software
 *   distributed under the License is distributed on an "AS IS" BASIS,
 *   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 *   See the License for the specific language governing permissions and
 *   limitations under the License.
17
 *
18 19 20
 */

/* tslint:disable:no-var-requires */
21
const retryPromise  = require('retry-promise').default
22

23
import {
L
lijiarui 已提交
24
  log,
Huan (李卓桓)'s avatar
wip...  
Huan (李卓桓) 已提交
25
}         from '../config'
26

27 28
import {
  WebRecomendInfo,
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
29 30 31
  WebMessageRawPayload,
  // FriendRequest,
}                             from './web-schemas'
32
import PuppetPuppeteer        from './puppet-puppeteer'
Huan (李卓桓)'s avatar
wip...  
Huan (李卓桓) 已提交
33

Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
34 35 36 37
import PuppeteerContact       from '../puppet/contact'
import {
  FriendRequest,
}                             from '../puppet/friend-request'
38
import {
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
39 40
  Message,
}                             from '../puppet/message'
41 42

/* tslint:disable:variable-name */
43
export const Firer = {
L
lijiarui 已提交
44 45
  checkFriendConfirm,
  checkFriendRequest,
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
46

L
lijiarui 已提交
47 48 49
  checkRoomJoin,
  checkRoomLeave,
  checkRoomTopic,
50

L
lijiarui 已提交
51 52 53 54
  parseFriendConfirm,
  parseRoomJoin,
  parseRoomLeave,
  parseRoomTopic,
55

56 57 58
}

const regexConfig = {
59
  friendConfirm: [
L
lijiarui 已提交
60 61
    /^You have added (.+) as your WeChat contact. Start chatting!$/,
    /^你已添加了(.+),现在可以开始聊天了。$/,
62 63
    /^(.+) just added you to his\/her contacts list. Send a message to him\/her now!$/,
    /^(.+)刚刚把你添加到通讯录,现在可以开始聊天了。$/,
L
lijiarui 已提交
64 65 66
  ],

  roomJoinInvite: [
L
lijiarui 已提交
67 68 69 70 71 72 73 74 75 76 77
    // There are 3 blank(charCode is 32) here. eg: You invited 管理员 to the group chat.
    /^(.+?) invited (.+) to the group chat.\s+$/,

    // There no no blank or punctuation here.  eg: 管理员 invited 小桔建群助手 to the group chat
    /^(.+?) invited (.+) to the group chat$/,

    // There are 2 blank(charCode is 32) here. eg: 你邀请"管理员"加入了群聊
    /^(.+?)邀请"(.+)"加入了群聊\s+$/,

    // There no no blank or punctuation here.  eg: "管理员"邀请"宁锐锋"加入了群聊
    /^"(.+?)"邀请"(.+)"加入了群聊$/,
L
lijiarui 已提交
78 79 80
  ],

  roomJoinQrcode: [
L
lijiarui 已提交
81 82 83 84 85 86 87
    // Wechat change this, should desperate. See more in pr#651
    // /^" (.+)" joined the group chat via the QR Code shared by "?(.+?)".$/,

    // There are 2 blank(charCode is 32) here. Qrcode is shared by bot.     eg: "管理员" joined group chat via the QR code you shared.
    /^"(.+)" joined group chat via the QR code "?(.+?)"? shared.\s+$/,

    // There are no blank(charCode is 32) here. Qrcode isn't shared by bot. eg: "宁锐锋" joined the group chat via the QR Code shared by "管理员".
L
lijiarui 已提交
88
    /^"(.+)" joined the group chat via the QR Code shared by "?(.+?)".$/,
L
lijiarui 已提交
89 90 91 92 93 94

    // There are 2 blank(charCode is 32) here. Qrcode is shared by bot.     eg: "管理员"通过扫描你分享的二维码加入群聊
    /^"(.+)"通过扫描(.+?)分享的二维码加入群聊\s+$/,

    // There are 1 blank(charCode is 32) here. Qrode isn't shared by bot.  eg: " 苏轼"通过扫描"管理员"分享的二维码加入群聊
    /^"\s+(.+)"通过扫描"(.+?)"分享的二维码加入群聊$/,
L
lijiarui 已提交
95 96
  ],

97 98
  // no list
  roomLeaveByBot: [
L
lijiarui 已提交
99 100 101
    /^You removed "(.+)" from the group chat$/,
    /^你将"(.+)"移出了群聊$/,
  ],
L
lijiarui 已提交
102

103 104 105 106 107
  roomLeaveByOther: [
    /^You were removed from the group chat by "(.+)"$/,
    /^你被"(.+)"移出群聊$/,
  ],

L
lijiarui 已提交
108 109 110 111
  roomTopic: [
    /^"?(.+?)"? changed the group name to "(.+)"$/,
    /^"?(.+?)"?修改群名为“(.+)”$/,
  ],
112 113
}

114
async function checkFriendRequest(
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
115 116
  this       : PuppetPuppeteer,
  rawPayload : WebMessageRawPayload,
117
): Promise<void> {
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
118
  if (!rawPayload.RecommendInfo) {
119
    throw new Error('no RecommendInfo')
120
  }
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
121 122
  const recommendInfo: WebRecomendInfo = rawPayload.RecommendInfo
  log.verbose('PuppetPuppeteerFirer', 'fireFriendRequest(%s)', recommendInfo)
123

Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
124 125
  if (!recommendInfo) {
    throw new Error('no recommendInfo')
126
  }
127

Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
128 129
  const contact   = PuppeteerContact.load(recommendInfo.UserName)
  contact.puppet  = this
130

Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
131 132
  const hello = recommendInfo.Content
  const ticket = recommendInfo.Ticket
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
133

134 135
  await contact.ready()
  if (!contact.isReady()) {
136
    log.warn('PuppetPuppeteerFirer', 'fireFriendConfirm() contact still not ready after `ready()` call')
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
137 138
  }

139 140 141 142 143
  const receivedRequest = FriendRequest.createReceive(
    contact,
    hello,
    ticket,
  )
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
144
  receivedRequest.puppet = this
145 146

  this.emit('friend', receivedRequest)
147 148 149 150 151
}

/**
 * try to find FriendRequest Confirmation Message
 */
152 153 154 155
function parseFriendConfirm(
  this: PuppetPuppeteer,
  content: string,
): boolean {
156 157
  const reList = regexConfig.friendConfirm
  let found = false
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
158

159 160
  reList.some(re => !!(found = re.test(content)))
  if (found) {
161 162 163 164 165 166
    return true
  } else {
    return false
  }
}

167 168
async function checkFriendConfirm(
  this: PuppetPuppeteer,
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
169
  m: Message,
170
) {
171
  const content = m.text()
172
  log.silly('PuppetPuppeteerFirer', 'fireFriendConfirm(%s)', content)
173

174
  if (!parseFriendConfirm.call(this, content)) {
175 176
    return
  }
177

Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
178
  const contact = m.from()
179

Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
180
  const confirmedRequest = FriendRequest.createConfirm(
181 182 183
    contact,
  )
  confirmedRequest.puppet = m.puppet
184

Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
185
  await contact.ready()
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
186
  if (!contact.isReady()) {
187
    log.warn('PuppetPuppeteerFirer', 'fireFriendConfirm() contact still not ready after `ready()` call')
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
188
  }
189 190

  this.emit('friend', confirmedRequest)
191 192 193 194 195
}

/**
 * try to find 'join' event for Room
 *
196
 * 1.
L
lijiarui 已提交
197 198
 *  You invited 管理员 to the group chat.
 *  You invited 李卓桓.PreAngel、Bruce LEE to the group chat.
199
 * 2.
L
lijiarui 已提交
200 201
 *  管理员 invited 小桔建群助手 to the group chat
 *  管理员 invited 庆次、小桔妹 to the group chat
202
 */
203 204 205 206
function parseRoomJoin(
  this: PuppetPuppeteer,
  content: string,
): [string[], string] {
207
  log.verbose('PuppetPuppeteerFirer', 'checkRoomJoin(%s)', content)
208

ruiruibupt's avatar
#155  
ruiruibupt 已提交
209 210
  const reListInvite = regexConfig.roomJoinInvite
  const reListQrcode = regexConfig.roomJoinQrcode
211

ruiruibupt's avatar
#155  
ruiruibupt 已提交
212 213 214 215 216
  let foundInvite: string[]|null = []
  reListInvite.some(re => !!(foundInvite = content.match(re)))
  let foundQrcode: string[]|null = []
  reListQrcode.some(re => !!(foundQrcode = content.match(re)))
  if ((!foundInvite || !foundInvite.length) && (!foundQrcode || !foundQrcode.length)) {
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
217
    throw new Error('checkRoomJoin() not found matched re of ' + content)
218
  }
219
  /**
L
lijiarui 已提交
220 221
   * 管理员 invited 庆次、小桔妹 to the group chat
   * "管理员"通过扫描你分享的二维码加入群聊
ruiruibupt's avatar
ruiruibupt 已提交
222
   */
ruiruibupt's avatar
#155  
ruiruibupt 已提交
223
  const [inviter, inviteeStr] = foundInvite ? [ foundInvite[1], foundInvite[2] ] : [ foundQrcode[2], foundQrcode[1] ]
224 225 226
  const inviteeList = inviteeStr.split(/、/)

  return [inviteeList, inviter] // put invitee at first place
227 228
}

229 230
async function checkRoomJoin(
  this: PuppetPuppeteer,
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
231
  msg:  Message,
232
): Promise<boolean> {
233

234
  const room = msg.room()
235
  if (!room) {
236
    log.warn('PuppetPuppeteerFirer', 'fireRoomJoin() `room` not found')
L
lijiarui 已提交
237
    return false
238 239
  }

240
  const text = msg.text()
241

Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
242 243
  let inviteeList: string[], inviter: string
  try {
244
    [inviteeList, inviter] = parseRoomJoin.call(this, text)
245
  } catch (e) {
246
    log.silly('PuppetPuppeteerFirer', 'fireRoomJoin() "%s" is not a join message', text)
L
lijiarui 已提交
247
    return false // not a room join message
248
  }
249
  log.silly('PuppetPuppeteerFirer', 'fireRoomJoin() inviteeList: %s, inviter: %s',
L
lijiarui 已提交
250 251
                              inviteeList.join(','),
                              inviter,
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
252
          )
253

254 255
  let inviterContact: PuppeteerContact | null = null
  let inviteeContactList: PuppeteerContact[] = []
256

257
  try {
L
lijiarui 已提交
258
    if (inviter === 'You' || inviter === '' || inviter === 'you') {
259
      inviterContact = this.userSelf()
260 261
    }

262 263 264 265
    const max = 20
    const backoff = 300
    const timeout = max * (backoff * max) / 2
    // 20 / 300 => 63,000
266 267 268
    // max = (2*totalTime/backoff) ^ (1/2)
    // timeout = 11,250 for {max: 15, backoff: 100}

269
    await retryPromise({ max: max, backoff: backoff }, async (attempt: number) => {
270
      log.silly('PuppetPuppeteerFirer', 'fireRoomJoin() retryPromise() attempt %d with timeout %d', attempt, timeout)
271

272
      await room.refresh()
273
      let inviteeListAllDone = true
274

275
      for (const i in inviteeList) {
276
        const loaded = inviteeContactList[i] instanceof PuppeteerContact
277 278

        if (!loaded) {
279
          const c = room.member(inviteeList[i])
280 281 282 283 284
          if (!c) {
            inviteeListAllDone = false
            continue
          }

Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
285 286 287
          await c.ready()
          inviteeContactList[i] = c

288 289 290 291
          const isReady = c.isReady()
          if (!isReady) {
            inviteeListAllDone = false
            continue
292 293
          }
        }
294

295
        if (inviteeContactList[i] instanceof PuppeteerContact) {
296 297
          const isReady = inviteeContactList[i].isReady()
          if (!isReady) {
298
            log.warn('PuppetPuppeteerFirer', 'fireRoomJoin() retryPromise() isReady false for contact %s', inviteeContactList[i].id)
299 300 301 302 303 304
            inviteeListAllDone = false
            await inviteeContactList[i].refresh()
            continue
          }
        }

305 306 307 308 309 310
      }

      if (!inviterContact) {
        inviterContact = room.member(inviter)
      }

311
      if (inviteeListAllDone && inviterContact) {
312 313
        log.silly('PuppetPuppeteerFirer', 'fireRoomJoin() resolve() inviteeContactList: %s, inviterContact: %s',
                                    inviteeContactList.map((c: PuppeteerContact) => c.name()).join(','),
L
lijiarui 已提交
314
                                    inviterContact.name(),
315
                )
L
lijiarui 已提交
316
        return true
317 318
      }

319
      log.error('PuppetPuppeteerFirer', 'fireRoomJoin() not found(yet)')
L
lijiarui 已提交
320 321
      return false
      // throw new Error('not found(yet)')
322

323
    }).catch((e: Error) => {
324
      log.warn('PuppetPuppeteerFirer', 'fireRoomJoin() reject() inviteeContactList: %s, inviterContact: %s, error %s',
325
                                 inviteeContactList.map((c: PuppeteerContact) => c.name()).join(','),
L
lijiarui 已提交
326
                                 inviter,
327
                                 e.message,
328
      )
329
    })
330

Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
331
    if (!inviterContact) {
332
      log.error('PuppetPuppeteerFirer', 'firmRoomJoin() inivter not found for %s , `room-join` & `join` event will not fired', inviter)
L
lijiarui 已提交
333
      return false
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
334
    }
335 336
    if (!inviteeContactList.every(c => c instanceof PuppeteerContact)) {
      log.error('PuppetPuppeteerFirer', 'firmRoomJoin() inviteeList not all found for %s , only part of them will in the `room-join` or `join` event',
L
lijiarui 已提交
337
                                  inviteeContactList.join(','),
338
              )
339
      inviteeContactList = inviteeContactList.filter(c => (c instanceof PuppeteerContact))
340
      if (inviteeContactList.length < 1) {
341
        log.error('PuppetPuppeteerFirer', 'firmRoomJoin() inviteeList empty.  `room-join` & `join` event will not fired')
L
lijiarui 已提交
342
        return false
343
      }
344
    }
345

346 347
    await Promise.all(inviteeContactList.map(c => c.ready()))
    await inviterContact.ready()
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
348
    await room.ready()
349

350 351
    this.emit('room-join', room , inviteeContactList, inviterContact)
    room.emit('join'            , inviteeContactList, inviterContact)
352

L
lijiarui 已提交
353
    return true
354
  } catch (e) {
355
    log.error('PuppetPuppeteerFirer', 'exception: %s', e.stack)
L
lijiarui 已提交
356
    return false
357 358
  }

359 360
}

361 362 363 364
function parseRoomLeave(
  this: PuppetPuppeteer,
  content: string,
): [string, string] {
365 366 367 368 369 370 371
  const reListByBot = regexConfig.roomLeaveByBot
  const reListByOther = regexConfig.roomLeaveByOther
  let foundByBot: string[]|null = []
  reListByBot.some(re => !!(foundByBot = content.match(re)))
  let foundByOther: string[]|null = []
  reListByOther.some(re => !!(foundByOther = content.match(re)))
  if ((!foundByBot || !foundByBot.length) && (!foundByOther || !foundByOther.length)) {
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
372
    throw new Error('checkRoomLeave() no matched re for ' + content)
373
  }
374
  const [leaver, remover] = foundByBot ? [ foundByBot[1], this.userSelf().id ] : [ this.userSelf().id, foundByOther[1] ]
375
  return [leaver, remover]
376 377 378 379 380
}

/**
 * You removed "Bruce LEE" from the group chat
 */
381 382
async function checkRoomLeave(
  this: PuppetPuppeteer,
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
383
  m:    Message,
384
): Promise<boolean> {
385
  log.verbose('PuppetPuppeteerFirer', 'fireRoomLeave(%s)', m.text())
386

387
  let leaver: string, remover: string
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
388
  try {
389
    [leaver, remover] = parseRoomLeave.call(this, m.text())
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
390
  } catch (e) {
L
lijiarui 已提交
391
    return false
392
  }
393
  log.silly('PuppetPuppeteerFirer', 'fireRoomLeave() got leaver: %s', leaver)
394

395
  const room = m.room()
396
  if (!room) {
397
    log.warn('PuppetPuppeteerFirer', 'fireRoomLeave() room not found')
L
lijiarui 已提交
398
    return false
399
  }
400 401
  /**
   * FIXME: leaver maybe is a list
402
   * @lijiarui: I have checked, leaver will never be a list. If the bot remove 2 leavers at the same time, it will be 2 sys message, instead of 1 sys message contains 2 leavers.
403
   */
404
  let leaverContact: PuppeteerContact | null, removerContact: PuppeteerContact | null
405
  if (leaver === this.userSelf().id) {
406
    leaverContact = this.userSelf()
407

408 409 410
    // not sure which is better
    // removerContact = room.member({contactAlias: remover}) || room.member({name: remover})
    removerContact = room.member(remover)
411 412 413 414
    // if (!removerContact) {
    //   log.error('PuppetPuppeteerFirer', 'fireRoomLeave() bot is removed from the room, but remover %s not found, event `room-leave` & `leave` will not be fired', remover)
    //   return false
    // }
415

416
  } else {
417
    removerContact = PuppeteerContact.load(this.userSelf().id)
418 419
    removerContact.puppet = m.puppet

420 421 422 423
    // not sure which is better
    // leaverContact = room.member({contactAlias: remover}) || room.member({name: leaver})
    leaverContact = room.member(remover)
    if (!leaverContact) {
424
      log.error('PuppetPuppeteerFirer', 'fireRoomLeave() bot removed someone from the room, but leaver %s not found, event `room-leave` & `leave` will not be fired', leaver)
L
lijiarui 已提交
425
      return false
426
    }
427
  }
428

429 430 431
  if (removerContact) {
    await removerContact.ready()
  }
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
432 433
  await leaverContact.ready()
  await room.ready()
434 435 436

  /**
   * FIXME: leaver maybe is a list
437 438 439
   * @lijiarui 2017: I have checked, leaver will never be a list. If the bot remove 2 leavers at the same time,
   *                  it will be 2 sys message, instead of 1 sys message contains 2 leavers.
   * @huan 2018 May: we need to generilize the pattern for future usage.
440
   */
441 442
  this.emit('room-leave', room, [leaverContact] /* , [removerContact] */)
  room.emit('leave'           , [leaverContact], removerContact || undefined)
443 444

  setTimeout(_ => { room.refresh() }, 10000) // reload the room data, especially for memberList
L
lijiarui 已提交
445
  return true
446 447
}

448 449 450 451
function parseRoomTopic(
  this: PuppetPuppeteer,
  content: string,
): [string, string] {
452
  const reList = regexConfig.roomTopic
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
453

454 455 456
  let found: string[]|null = []
  reList.some(re => !!(found = content.match(re)))
  if (!found || !found.length) {
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
457
    throw new Error('checkRoomTopic() not found')
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
458
  }
459
  const [, changer, topic] = found
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
460 461 462
  return [topic, changer]
}

463 464
async function checkRoomTopic(
  this: PuppetPuppeteer,
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
465
  m: Message): Promise<boolean> {
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
466 467
  let  topic, changer
  try {
468
    [topic, changer] = parseRoomTopic.call(this, m.text())
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
469
  } catch (e) { // not found
L
lijiarui 已提交
470
    return false
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
471 472 473
  }

  const room = m.room()
474
  if (!room) {
475
    log.warn('PuppetPuppeteerFirer', 'fireRoomLeave() room not found')
L
lijiarui 已提交
476
    return false
477 478
  }

Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
479 480
  const oldTopic = room.topic()

481
  let changerContact: PuppeteerContact | null
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
482
  if (/^You$/.test(changer) || /^你$/.test(changer)) {
483
    changerContact = this.userSelf()
484
    changerContact.puppet = m.puppet
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
485 486 487
  } else {
    changerContact = room.member(changer)
  }
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
488 489

  if (!changerContact) {
490
    log.error('PuppetPuppeteerFirer', 'fireRoomTopic() changer contact not found for %s', changer)
L
lijiarui 已提交
491
    return false
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
492 493
  }

494 495
  try {
    await changerContact.ready()
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
496
    await room.ready()
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
497
    this.emit('room-topic', room, topic, oldTopic, changerContact)
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
498
    room.emit('topic'           , topic, oldTopic, changerContact)
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
499
    room.refresh()
L
lijiarui 已提交
500
    return true
501
  } catch (e) {
502
    log.error('PuppetPuppeteerFirer', 'fireRoomTopic() co exception: %s', e.stack)
L
lijiarui 已提交
503
    return false
504
  }
Huan (李卓桓)'s avatar
Huan (李卓桓) 已提交
505
}
Huan (李卓桓)'s avatar
merge  
Huan (李卓桓) 已提交
506 507

export default Firer