/** * * Wechaty: * * Wechaty - Wechat for Bot. Connecting ChatBots * * Licenst: ISC * https://github.com/wechaty/wechaty * */ import { Config, RecommendInfo, Sayable, log, } from './config' import { Contact } from './contact' import { Room } from './room' import { UtilLib } from './util-lib' export type MsgRawObj = { MsgId: string, MMActualSender: string, // getUserContact(message.MMActualSender,message.MMPeerUserName).isContact() MMPeerUserName: string, // message.MsgType == CONF.MSGTYPE_TEXT && message.MMPeerUserName == 'newsapp' ToUserName: string, MMActualContent: string, // Content has @id prefix added by wx MMDigest: string, MMDisplayTime: number, // Javascript timestamp of milliseconds /** * MsgType == MSGTYPE_APP && message.AppMsgType == CONF.APPMSGTYPE_URL * class="cover" mm-src="{{getMsgImg(message.MsgId,'slave')}}" */ Url: string, MMAppMsgDesc: string, // class="desc" ng-bind="message.MMAppMsgDesc" /** * Attachment * * MsgType == MSGTYPE_APP && message.AppMsgType == CONF.APPMSGTYPE_ATTACH */ FileName: string, // FileName: '钢甲互联项目BP1108.pdf', FileSize: number, // FileSize: '2845701', MediaId: string, // MediaId: '@crypt_b1a45e3f_c21dceb3ac01349... MMAppMsgFileExt: string, // doc, docx ... 'undefined'? MMAppMsgFileSize: string, // '2.7MB', MMAppMsgDownloadUrl: string, // 'https://file.wx.qq.com/cgi-bin/mmwebwx-bin/webwxgetmedia?sender=@4f549c2dafd5ad731afa4d857bf03c10&mediaid=@crypt_b1a45e3f // 下载 MMUploadProgress: number, // < 100 /** * 模板消息 * MSGTYPE_APP && message.AppMsgType == CONF.APPMSGTYPE_READER_TYPE * item.url * item.title * item.pub_time * item.cover * item.digest */ MMCategory: any[], // item in message.MMCategory /** * Type * * MsgType == CONF.MSGTYPE_VOICE : ng-style="{'width':40 + 7*message.VoiceLength/1000} */ MsgType: number, AppMsgType: AppMsgType, // message.MsgType == CONF.MSGTYPE_APP && message.AppMsgType == CONF.APPMSGTYPE_URL // message.MsgType == CONF.MSGTYPE_TEXT && message.SubMsgType != CONF.MSGTYPE_LOCATION SubMsgType: MsgType, // "msgType":"{{message.MsgType}}","subType":{{message.SubMsgType||0}},"msgId":"{{message.MsgId}}" /** * Status-es */ Status: string, MMStatus: number, // img ng-show="message.MMStatus == 1" class="ico_loading" // ng-click="resendMsg(message)" ng-show="message.MMStatus == 5" title="重新发送" MMFileStatus: number, //

// CONF.MM_SEND_FILE_STATUS_QUEUED, MM_SEND_FILE_STATUS_SENDING /** * Location */ MMLocationUrl: string, // ng-if="message.MsgType == CONF.MSGTYPE_TEXT && message.SubMsgType == CONF.MSGTYPE_LOCATION" // // 'http://apis.map.qq.com/uri/v1/geocoder?coord=40.075041,116.338994' MMLocationDesc: string, // MMLocationDesc: '北京市昌平区回龙观龙腾苑(五区)内(龙腾街南)', /** * MsgType == CONF.MSGTYPE_EMOTICON * * getMsgImg(message.MsgId,'big',message) */ /** * Image * * getMsgImg(message.MsgId,'slave') */ MMImgStyle: string, // ng-style="message.MMImgStyle" MMPreviewSrc: string, // message.MMPreviewSrc || message.MMThumbSrc || getMsgImg(message.MsgId,'slave') MMThumbSrc: string, /** * Friend Request & ShareCard ? * * MsgType == CONF.MSGTYPE_SHARECARD" ng-click="showProfile($event,message.RecommendInfo.UserName) * MsgType == CONF.MSGTYPE_VERIFYMSG */ RecommendInfo?: RecommendInfo, } export type MsgObj = { id: string, type: MsgType, from: string, to?: string, // if to is not set, then room must be set room?: string, content: string, status: string, digest: string, date: string, url?: string, // for MessageMedia class } // export type MessageTypeName = 'TEXT' | 'IMAGE' | 'VOICE' | 'VERIFYMSG' | 'POSSIBLEFRIEND_MSG' // | 'SHARECARD' | 'VIDEO' | 'EMOTICON' | 'LOCATION' | 'APP' | 'VOIPMSG' | 'STATUSNOTIFY' // | 'VOIPNOTIFY' | 'VOIPINVITE' | 'MICROVIDEO' | 'SYSNOTICE' | 'SYS' | 'RECALLED' // export type MessageTypeValue = 1 | 3 | 34 | 37 | 40 | 42 | 43 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 62 | 9999 | 10000 | 10002 export type MsgTypeMap = { [index: string]: string|number, // MessageTypeName: MessageTypeValue // , MessageTypeValue: MessageTypeName } export const enum AppMsgType { TEXT = 1, IMG = 2, AUDIO = 3, VIDEO = 4, URL = 5, ATTACH = 6, OPEN = 7, EMOJI = 8, VOICE_REMIND = 9, SCAN_GOOD = 10, GOOD = 13, EMOTION = 15, CARD_TICKET = 16, REALTIME_SHARE_LOCATION = 17, TRANSFERS = 2e3, RED_ENVELOPES = 2001, READER_TYPE = 100001, } export const enum MsgType { TEXT = 1, IMAGE = 3, VOICE = 34, VERIFYMSG = 37, POSSIBLEFRIEND_MSG = 40, SHARECARD = 42, VIDEO = 43, EMOTICON = 47, LOCATION = 48, APP = 49, VOIPMSG = 50, STATUSNOTIFY = 51, VOIPNOTIFY = 52, VOIPINVITE = 53, MICROVIDEO = 62, SYSNOTICE = 9999, SYS = 10000, RECALLED = 10002, } export class Message implements Sayable { public static counter = 0 public _counter: number /** * a map for: * 1. name to id * 2. id to name */ public static TYPE: MsgTypeMap = { TEXT: 1, IMAGE: 3, VOICE: 34, VERIFYMSG: 37, POSSIBLEFRIEND_MSG: 40, SHARECARD: 42, VIDEO: 43, EMOTICON: 47, LOCATION: 48, APP: 49, VOIPMSG: 50, STATUSNOTIFY: 51, VOIPNOTIFY: 52, VOIPINVITE: 53, MICROVIDEO: 62, SYSNOTICE: 9999, SYS: 10000, RECALLED: 10002, } public readonly id: string protected obj = {} public readyStream(): Promise { throw Error('abstract method') } public filename(): string { throw Error('not a media message') } constructor(public rawObj?: MsgRawObj) { this._counter = Message.counter++ log.silly('Message', 'constructor() SN:%d', this._counter) if (typeof rawObj === 'string') { this.rawObj = JSON.parse(rawObj) } this.rawObj = rawObj = rawObj || {} this.obj = this.parse(rawObj) this.id = this.obj.id } // Transform rawObj to local m private parse(rawObj): MsgObj { const obj: MsgObj = { id: rawObj.MsgId, type: rawObj.MsgType, from: rawObj.MMActualSender, // MMPeerUserName to: rawObj.ToUserName, content: rawObj.MMActualContent, // Content has @id prefix added by wx status: rawObj.Status, digest: rawObj.MMDigest, date: rawObj.MMDisplayTime, // Javascript timestamp of milliseconds url: rawObj.Url || rawObj.MMAppMsgDownloadUrl || rawObj.MMLocationUrl, } // FIXME: has ther any better method to know the room ID? if (rawObj.MMIsChatRoom) { if (/^@@/.test(rawObj.FromUserName)) { obj.room = rawObj.FromUserName // MMPeerUserName always eq FromUserName ? } else if (/^@@/.test(rawObj.ToUserName)) { obj.room = rawObj.ToUserName } else { log.error('Message', 'parse found a room message, but neither FromUserName nor ToUserName is a room(/^@@/)') // obj.room = undefined // bug compatible } if (obj.to && /^@@/.test(obj.to)) { // if a message in room without any specific receiver, then it will set to be `undefined` obj.to = undefined } } return obj } public toString() { return UtilLib.plainText(this.obj.content) } public toStringDigest() { const text = UtilLib.digestEmoji(this.obj.digest) return '{' + this.typeEx() + '}' + text } public toStringEx() { let s = `${this.constructor.name}#${this._counter}` s += '(' + this.getSenderString() s += ':' + this.getContentString() + ')' return s } public getSenderString() { const fromName = Contact.load(this.obj.from).name() const roomTopic = this.obj.room ? (':' + Room.load(this.obj.room).topic()) : '' return `<${fromName}${roomTopic}>` } public getContentString() { let content = UtilLib.plainText(this.obj.content) if (content.length > 20) { content = content.substring(0, 17) + '...' } return '{' + this.type() + '}' + content } public from(contact: Contact): void public from(id: string): void public from(): Contact public from(contact?: Contact|string): Contact|void { if (contact) { if (contact instanceof Contact) { this.obj.from = contact.id } else if (typeof contact === 'string') { this.obj.from = contact } else { throw new Error('unsupport from param: ' + typeof contact) } return } const loadedContact = Contact.load(this.obj.from) if (!loadedContact) { throw new Error('no from') } return loadedContact } // public to(room: Room): void // public to(): Contact|Room // public to(contact?: Contact|Room|string): Contact|Room|void { public to(contact: Contact): void public to(id: string): void public to(): Contact|null // if to is not set, then room must had set public to(contact?: Contact|string): Contact|Room|null|void { if (contact) { if (contact instanceof Contact) { this.obj.to = contact.id } else if (typeof contact === 'string') { this.obj.to = contact } else { throw new Error('unsupport to param ' + typeof contact) } return } // no parameter if (!this.obj.to) { return null } return Contact.load(this.obj.to) } public room(room: Room): void public room(id: string): void public room(): Room|null public room(room?: Room|string): Room|null|void { if (room) { if (room instanceof Room) { this.obj.room = room.id } else if (typeof room === 'string') { this.obj.room = room } else { throw new Error('unsupport room param ' + typeof room) } return } if (this.obj.room) { return Room.load(this.obj.room) } return null } public content(): string public content(content: string): void public content(content?: string): string|void { if (content) { this.obj.content = content return } return this.obj.content } public type(): MsgType { return this.obj.type } public typeSub(): MsgType { if (!this.rawObj) { throw new Error('no rawObj') } return this.rawObj.SubMsgType } public typeApp(): AppMsgType { if (!this.rawObj) { throw new Error('no rawObj') } return this.rawObj.AppMsgType } public typeEx() { return Message.TYPE[this.obj.type] } public count() { return this._counter } public self(): boolean { const userId = Config.puppetInstance() .userId const fromId = this.obj.from if (!userId || !fromId) { throw new Error('no user or no from') } return fromId === userId } // public ready() { // log.warn('Message', 'ready() DEPRECATED. use load() instead.') // return this.ready() // } public async ready(): Promise { log.silly('Message', 'ready()') try { const from = Contact.load(this.obj.from) await from.ready() // Contact from if (this.obj.to) { const to = Contact.load(this.obj.to) await to.ready() } if (this.obj.room) { const room = Room.load(this.obj.room) await room.ready() // Room member list } } catch (e) { log.error('Message', 'ready() exception: %s', e.stack) // console.log(e) // this.dump() // this.dumpRaw() throw e } } /** * @deprecated */ public get(prop: string): string { log.warn('Message', 'DEPRECATED get() at %s', new Error('stack').stack) if (!prop || !(prop in this.obj)) { const s = '[' + Object.keys(this.obj).join(',') + ']' throw new Error(`Message.get(${prop}) must be in: ${s}`) } return this.obj[prop] } /** * @deprecated */ public set(prop: string, value: string): this { log.warn('Message', 'DEPRECATED set() at %s', new Error('stack').stack) if (typeof value !== 'string') { throw new Error('value must be string, we got: ' + typeof value) } this.obj[prop] = value return this } public dump() { console.error('======= dump message =======') Object.keys(this.obj).forEach(k => console.error(`${k}: ${this.obj[k]}`)) } public dumpRaw() { console.error('======= dump raw message =======') Object.keys(this.rawObj).forEach(k => console.error(`${k}: ${this.rawObj && this.rawObj[k]}`)) } public static async find(query) { return Promise.resolve(new Message({MsgId: '-1'})) } public static async findAll(query) { return Promise.resolve([ new Message ({MsgId: '-2'}), new Message ({MsgId: '-3'}), ]) } public static initType() { Object.keys(Message.TYPE).forEach(k => { const v = Message.TYPE[k] Message.TYPE[v] = k // Message.Type[1] = 'TEXT' }) } public say(content: string, replyTo?: Contact|Contact[]): Promise { log.verbose('Message', 'say(%s, %s)', content, replyTo) const m = new Message() const room = this.room() if (room) { m.room(room) } if (!replyTo) { m.to(this.from()) m.content(content) } else if (this.room()) { let mentionList if (Array.isArray(replyTo)) { m.to(replyTo[0]) mentionList = replyTo.map(c => '@' + c.name()).join(' ') } else { m.to(replyTo) mentionList = '@' + replyTo.name() } m.content(mentionList + ' ' + content) } return Config.puppetInstance() .send(m) } } Message.initType() export * from './message-media' /* * join room in mac client: https://support.weixin.qq.com/cgi-bin/ * mmsupport-bin/addchatroombyinvite * ?ticket=AUbv%2B4GQA1Oo65ozlIqRNw%3D%3D&exportkey=AS9GWEg4L82fl3Y8e2OeDbA%3D * &lang=en&pass_ticket=T6dAZXE27Y6R29%2FFppQPqaBlNwZzw9DAN5RJzzzqeBA%3D * &wechat_real_lang=en */