@juzi/wechaty 1.0.144 → 1.0.147
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/src/mods/impls.d.ts +2 -2
- package/dist/cjs/src/mods/impls.d.ts.map +1 -1
- package/dist/cjs/src/mods/impls.js +2 -1
- package/dist/cjs/src/mods/impls.js.map +1 -1
- package/dist/cjs/src/mods/users.d.ts +2 -1
- package/dist/cjs/src/mods/users.d.ts.map +1 -1
- package/dist/cjs/src/package-json.js +4 -4
- package/dist/cjs/src/package-json.js.map +1 -1
- package/dist/cjs/src/schemas/call-events.d.ts +20 -0
- package/dist/cjs/src/schemas/call-events.d.ts.map +1 -0
- package/dist/cjs/src/schemas/call-events.js +7 -0
- package/dist/cjs/src/schemas/call-events.js.map +1 -0
- package/dist/cjs/src/schemas/mod.d.ts +3 -2
- package/dist/cjs/src/schemas/mod.d.ts.map +1 -1
- package/dist/cjs/src/schemas/mod.js +3 -1
- package/dist/cjs/src/schemas/mod.js.map +1 -1
- package/dist/cjs/src/schemas/wechaty-events.d.ts +23 -2
- package/dist/cjs/src/schemas/wechaty-events.d.ts.map +1 -1
- package/dist/cjs/src/schemas/wechaty-events.js +6 -0
- package/dist/cjs/src/schemas/wechaty-events.js.map +1 -1
- package/dist/cjs/src/user-modules/call.d.ts +158 -0
- package/dist/cjs/src/user-modules/call.d.ts.map +1 -1
- package/dist/cjs/src/user-modules/call.js +289 -1
- package/dist/cjs/src/user-modules/call.js.map +1 -1
- package/dist/cjs/src/user-modules/call.spec.d.ts +7 -0
- package/dist/cjs/src/user-modules/call.spec.d.ts.map +1 -0
- package/dist/cjs/src/user-modules/call.spec.js +759 -0
- package/dist/cjs/src/user-modules/call.spec.js.map +1 -0
- package/dist/cjs/src/user-modules/contact.d.ts +16 -0
- package/dist/cjs/src/user-modules/contact.d.ts.map +1 -1
- package/dist/cjs/src/user-modules/contact.js +16 -0
- package/dist/cjs/src/user-modules/contact.js.map +1 -1
- package/dist/cjs/src/user-modules/message.d.ts +10 -1
- package/dist/cjs/src/user-modules/message.d.ts.map +1 -1
- package/dist/cjs/src/user-modules/message.js +69 -0
- package/dist/cjs/src/user-modules/message.js.map +1 -1
- package/dist/cjs/src/user-modules/message.spec.js +65 -0
- package/dist/cjs/src/user-modules/message.spec.js.map +1 -1
- package/dist/cjs/src/user-modules/mod.d.ts +5 -4
- package/dist/cjs/src/user-modules/mod.d.ts.map +1 -1
- package/dist/cjs/src/user-modules/mod.js +2 -1
- package/dist/cjs/src/user-modules/mod.js.map +1 -1
- package/dist/cjs/src/wechaty/wechaty-base.d.ts +66 -7
- package/dist/cjs/src/wechaty/wechaty-base.d.ts.map +1 -1
- package/dist/cjs/src/wechaty/wechaty-base.js +28 -0
- package/dist/cjs/src/wechaty/wechaty-base.js.map +1 -1
- package/dist/cjs/src/wechaty/wechaty-impl.spec.js +10 -8
- package/dist/cjs/src/wechaty/wechaty-impl.spec.js.map +1 -1
- package/dist/cjs/src/wechaty-mixins/gerror-mixin.d.ts +1 -1
- package/dist/cjs/src/wechaty-mixins/io-mixin.d.ts +2 -2
- package/dist/cjs/src/wechaty-mixins/login-mixin.d.ts +15 -5
- package/dist/cjs/src/wechaty-mixins/login-mixin.d.ts.map +1 -1
- package/dist/cjs/src/wechaty-mixins/misc-mixin.d.ts +15 -5
- package/dist/cjs/src/wechaty-mixins/misc-mixin.d.ts.map +1 -1
- package/dist/cjs/src/wechaty-mixins/plugin-mixin.d.ts +32 -6
- package/dist/cjs/src/wechaty-mixins/plugin-mixin.d.ts.map +1 -1
- package/dist/cjs/src/wechaty-mixins/puppet-mixin.d.ts +39 -5
- package/dist/cjs/src/wechaty-mixins/puppet-mixin.d.ts.map +1 -1
- package/dist/cjs/src/wechaty-mixins/puppet-mixin.js +149 -0
- package/dist/cjs/src/wechaty-mixins/puppet-mixin.js.map +1 -1
- package/dist/cjs/src/wechaty-mixins/wechatify-user-module-mixin.d.ts +5 -3
- package/dist/cjs/src/wechaty-mixins/wechatify-user-module-mixin.d.ts.map +1 -1
- package/dist/cjs/src/wechaty-mixins/wechatify-user-module-mixin.js +3 -0
- package/dist/cjs/src/wechaty-mixins/wechatify-user-module-mixin.js.map +1 -1
- package/dist/esm/src/mods/impls.d.ts +2 -2
- package/dist/esm/src/mods/impls.d.ts.map +1 -1
- package/dist/esm/src/mods/impls.js +1 -1
- package/dist/esm/src/mods/impls.js.map +1 -1
- package/dist/esm/src/mods/users.d.ts +2 -1
- package/dist/esm/src/mods/users.d.ts.map +1 -1
- package/dist/esm/src/package-json.js +4 -4
- package/dist/esm/src/package-json.js.map +1 -1
- package/dist/esm/src/schemas/call-events.d.ts +20 -0
- package/dist/esm/src/schemas/call-events.d.ts.map +1 -0
- package/dist/esm/src/schemas/call-events.js +4 -0
- package/dist/esm/src/schemas/call-events.js.map +1 -0
- package/dist/esm/src/schemas/mod.d.ts +3 -2
- package/dist/esm/src/schemas/mod.d.ts.map +1 -1
- package/dist/esm/src/schemas/mod.js +2 -1
- package/dist/esm/src/schemas/mod.js.map +1 -1
- package/dist/esm/src/schemas/wechaty-events.d.ts +23 -2
- package/dist/esm/src/schemas/wechaty-events.d.ts.map +1 -1
- package/dist/esm/src/schemas/wechaty-events.js +6 -0
- package/dist/esm/src/schemas/wechaty-events.js.map +1 -1
- package/dist/esm/src/user-modules/call.d.ts +158 -0
- package/dist/esm/src/user-modules/call.d.ts.map +1 -1
- package/dist/esm/src/user-modules/call.js +289 -1
- package/dist/esm/src/user-modules/call.js.map +1 -1
- package/dist/esm/src/user-modules/call.spec.d.ts +7 -0
- package/dist/esm/src/user-modules/call.spec.d.ts.map +1 -0
- package/dist/esm/src/user-modules/call.spec.js +734 -0
- package/dist/esm/src/user-modules/call.spec.js.map +1 -0
- package/dist/esm/src/user-modules/contact.d.ts +16 -0
- package/dist/esm/src/user-modules/contact.d.ts.map +1 -1
- package/dist/esm/src/user-modules/contact.js +16 -0
- package/dist/esm/src/user-modules/contact.js.map +1 -1
- package/dist/esm/src/user-modules/message.d.ts +10 -1
- package/dist/esm/src/user-modules/message.d.ts.map +1 -1
- package/dist/esm/src/user-modules/message.js +69 -0
- package/dist/esm/src/user-modules/message.js.map +1 -1
- package/dist/esm/src/user-modules/message.spec.js +65 -0
- package/dist/esm/src/user-modules/message.spec.js.map +1 -1
- package/dist/esm/src/user-modules/mod.d.ts +5 -4
- package/dist/esm/src/user-modules/mod.d.ts.map +1 -1
- package/dist/esm/src/user-modules/mod.js +2 -2
- package/dist/esm/src/user-modules/mod.js.map +1 -1
- package/dist/esm/src/wechaty/wechaty-base.d.ts +66 -7
- package/dist/esm/src/wechaty/wechaty-base.d.ts.map +1 -1
- package/dist/esm/src/wechaty/wechaty-base.js +28 -0
- package/dist/esm/src/wechaty/wechaty-base.js.map +1 -1
- package/dist/esm/src/wechaty/wechaty-impl.spec.js +10 -8
- package/dist/esm/src/wechaty/wechaty-impl.spec.js.map +1 -1
- package/dist/esm/src/wechaty-mixins/gerror-mixin.d.ts +1 -1
- package/dist/esm/src/wechaty-mixins/io-mixin.d.ts +2 -2
- package/dist/esm/src/wechaty-mixins/login-mixin.d.ts +15 -5
- package/dist/esm/src/wechaty-mixins/login-mixin.d.ts.map +1 -1
- package/dist/esm/src/wechaty-mixins/misc-mixin.d.ts +15 -5
- package/dist/esm/src/wechaty-mixins/misc-mixin.d.ts.map +1 -1
- package/dist/esm/src/wechaty-mixins/plugin-mixin.d.ts +32 -6
- package/dist/esm/src/wechaty-mixins/plugin-mixin.d.ts.map +1 -1
- package/dist/esm/src/wechaty-mixins/puppet-mixin.d.ts +39 -5
- package/dist/esm/src/wechaty-mixins/puppet-mixin.d.ts.map +1 -1
- package/dist/esm/src/wechaty-mixins/puppet-mixin.js +149 -0
- package/dist/esm/src/wechaty-mixins/puppet-mixin.js.map +1 -1
- package/dist/esm/src/wechaty-mixins/wechatify-user-module-mixin.d.ts +5 -3
- package/dist/esm/src/wechaty-mixins/wechatify-user-module-mixin.d.ts.map +1 -1
- package/dist/esm/src/wechaty-mixins/wechatify-user-module-mixin.js +4 -1
- package/dist/esm/src/wechaty-mixins/wechatify-user-module-mixin.js.map +1 -1
- package/package.json +3 -3
- package/src/mods/impls.ts +2 -0
- package/src/mods/users.ts +6 -0
- package/src/package-json.ts +4 -4
- package/src/schemas/call-events.ts +35 -0
- package/src/schemas/mod.ts +6 -0
- package/src/schemas/wechaty-events.ts +28 -0
- package/src/user-modules/call.spec.ts +929 -0
- package/src/user-modules/call.ts +373 -0
- package/src/user-modules/contact.ts +18 -0
- package/src/user-modules/message.spec.ts +89 -0
- package/src/user-modules/message.ts +139 -2
- package/src/user-modules/mod.ts +11 -0
- package/src/wechaty/wechaty-base.ts +40 -1
- package/src/wechaty/wechaty-impl.spec.ts +4 -0
- package/src/wechaty-mixins/puppet-mixin.ts +184 -0
- package/src/wechaty-mixins/wechatify-user-module-mixin.ts +6 -0
package/src/user-modules/call.ts
CHANGED
|
@@ -6,10 +6,383 @@ import { log } from '../config.js'
|
|
|
6
6
|
import { validationMixin } from '../user-mixins/validation.js'
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
|
+
wechatifyMixin,
|
|
9
10
|
wechatifyMixinBase,
|
|
10
11
|
} from '../user-mixins/wechatify.js'
|
|
12
|
+
import { CallEventEmitter } from '../schemas/call-events.js'
|
|
11
13
|
import type { ContactImpl, ContactInterface } from './contact.js'
|
|
12
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Lifecycle status of a call session as seen from this side.
|
|
17
|
+
*
|
|
18
|
+
* Intentionally defined here (not in wechaty-puppet) because the puppet's
|
|
19
|
+
* `CallStatus` enum is already occupied by the call-record domain.
|
|
20
|
+
*/
|
|
21
|
+
export type CallStatus = 'calling' | 'ringing' | 'connected' | 'ended'
|
|
22
|
+
export type CallDirection = 'outgoing' | 'incoming'
|
|
23
|
+
|
|
24
|
+
interface CallConstructorOptions {
|
|
25
|
+
readonly id : string
|
|
26
|
+
readonly direction : CallDirection
|
|
27
|
+
/** If omitted the status is derived: outgoing → calling, incoming → ringing */
|
|
28
|
+
readonly status? : CallStatus
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const CallMixinBase = wechatifyMixin(CallEventEmitter)
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Call – a live, stateful call-control session abstraction.
|
|
35
|
+
*
|
|
36
|
+
* A Call is a first-class citizen alongside Message and Contact.
|
|
37
|
+
* It models the signaling lifecycle of a call (not the media connection itself;
|
|
38
|
+
* media runs through a direct gateway link addressed by callId).
|
|
39
|
+
*
|
|
40
|
+
* Outgoing: obtain via `bot.call([contact, ...])` or `contact.call()`.
|
|
41
|
+
* Incoming: received as the argument of the `bot.on('call', …)` event.
|
|
42
|
+
*
|
|
43
|
+
* State (media, roster, lifecycle timestamps) is hydrated from the puppet via
|
|
44
|
+
* `ready()` / `sync()`. The dirty stream (`Dirty.Call`) drives cache invalidation.
|
|
45
|
+
*/
|
|
46
|
+
class CallMixin extends CallMixinBase {
|
|
47
|
+
|
|
48
|
+
readonly id : string
|
|
49
|
+
|
|
50
|
+
private __direction : CallDirection
|
|
51
|
+
private __status : CallStatus
|
|
52
|
+
private __payload? : PUPPET.payloads.Call
|
|
53
|
+
private __endedEmitted = false
|
|
54
|
+
|
|
55
|
+
constructor (options: CallConstructorOptions) {
|
|
56
|
+
super()
|
|
57
|
+
|
|
58
|
+
this.id = options.id
|
|
59
|
+
this.__direction = options.direction
|
|
60
|
+
this.__status = options.status ?? (options.direction === 'outgoing' ? 'calling' : 'ringing')
|
|
61
|
+
|
|
62
|
+
log.verbose('Call', 'constructor(%s, dir=%s, status=%s)', this.id, this.__direction, this.__status)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
direction (): CallDirection { return this.__direction }
|
|
66
|
+
status (): CallStatus { return this.__status }
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The media type of the call (audio | video).
|
|
70
|
+
* Requires the payload to be hydrated; throws otherwise.
|
|
71
|
+
*/
|
|
72
|
+
media (): PUPPET.types.CallMediaType {
|
|
73
|
+
if (!this.__payload) {
|
|
74
|
+
throw new Error(`Call ${this.id} not ready: call ready() first`)
|
|
75
|
+
}
|
|
76
|
+
return this.__payload.media
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* When the call was initiated (protocol-side clock).
|
|
81
|
+
* Requires the payload to be hydrated; throws otherwise.
|
|
82
|
+
*/
|
|
83
|
+
startTime (): Date {
|
|
84
|
+
if (!this.__payload) {
|
|
85
|
+
throw new Error(`Call ${this.id} not ready: call ready() first`)
|
|
86
|
+
}
|
|
87
|
+
return new Date(this.__payload.startTime)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* When the call terminated. Becomes defined once a dirty refresh observes
|
|
92
|
+
* the protocol-side endTime; returns undefined while the call is live.
|
|
93
|
+
*/
|
|
94
|
+
endTime (): Date | undefined {
|
|
95
|
+
if (!this.__payload?.endTime) {
|
|
96
|
+
return undefined
|
|
97
|
+
}
|
|
98
|
+
return new Date(this.__payload.endTime)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The participant who started the call. Returns undefined when the protocol
|
|
103
|
+
* payload does not identify a starter (rare; defensive).
|
|
104
|
+
*/
|
|
105
|
+
async starter (): Promise<ContactInterface | undefined> {
|
|
106
|
+
if (!this.__payload) {
|
|
107
|
+
await this.ready()
|
|
108
|
+
}
|
|
109
|
+
const starterId = this.__payload!.starter
|
|
110
|
+
if (!starterId) {
|
|
111
|
+
return undefined
|
|
112
|
+
}
|
|
113
|
+
return (this.wechaty.Contact as typeof ContactImpl).find({ id: starterId })
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Current participant roster. The list is maintained server-side and refreshed
|
|
118
|
+
* via the dirty mechanism; consumers should expect this to change between calls.
|
|
119
|
+
*/
|
|
120
|
+
async participants (): Promise<ContactInterface[]> {
|
|
121
|
+
if (!this.__payload) {
|
|
122
|
+
await this.ready()
|
|
123
|
+
}
|
|
124
|
+
const found = await Promise.all(
|
|
125
|
+
this.__payload!.participants.map(id => (this.wechaty.Contact as typeof ContactImpl).find({ id })),
|
|
126
|
+
)
|
|
127
|
+
return found.filter((c): c is ContactInterface => !!c)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Hydrate the call payload from the puppet. A no-op if already hydrated
|
|
132
|
+
* unless `forceSync` is true.
|
|
133
|
+
*/
|
|
134
|
+
async ready (forceSync = false): Promise<void> {
|
|
135
|
+
if (!forceSync && this.__payload) {
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
this.__payload = await this.wechaty.puppet.callPayload(this.id)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Force-invalidate the cached payload and re-pull it. Used after a dirty
|
|
143
|
+
* signal or when the local view is suspected to be stale.
|
|
144
|
+
*/
|
|
145
|
+
async sync (): Promise<void> {
|
|
146
|
+
await this.wechaty.puppet.callPayloadDirty(this.id)
|
|
147
|
+
await this.ready(true)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// Control methods
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Accept an incoming call. Only valid when direction=incoming, status=ringing.
|
|
156
|
+
*/
|
|
157
|
+
async accept (): Promise<void> {
|
|
158
|
+
if (this.__direction !== 'incoming' || this.__status !== 'ringing') {
|
|
159
|
+
throw new Error(
|
|
160
|
+
`Call.accept() invalid: direction=${this.__direction}, status=${this.__status}. `
|
|
161
|
+
+ 'Only valid for incoming calls in ringing state.',
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
await this.wechaty.puppet.callAccept(this.id)
|
|
165
|
+
this.__transitionTo('connected')
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Reject an incoming call. Only valid when direction=incoming, status=ringing.
|
|
170
|
+
* Local state transitions to 'ended' regardless of whether the protocol
|
|
171
|
+
* acknowledgement reaches the peer.
|
|
172
|
+
*/
|
|
173
|
+
async reject (reason?: string): Promise<void> {
|
|
174
|
+
if (this.__direction !== 'incoming' || this.__status !== 'ringing') {
|
|
175
|
+
throw new Error(
|
|
176
|
+
`Call.reject() invalid: direction=${this.__direction}, status=${this.__status}. `
|
|
177
|
+
+ 'Only valid for incoming calls in ringing state.',
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
try {
|
|
181
|
+
await this.wechaty.puppet.callReject(this.id, reason)
|
|
182
|
+
} finally {
|
|
183
|
+
this.__finalize()
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Cancel an outgoing call before it is connected.
|
|
189
|
+
* Only valid when direction=outgoing and status is calling or ringing.
|
|
190
|
+
*/
|
|
191
|
+
async cancel (): Promise<void> {
|
|
192
|
+
if (this.__direction !== 'outgoing') {
|
|
193
|
+
throw new Error(
|
|
194
|
+
`Call.cancel() invalid: direction=${this.__direction}. `
|
|
195
|
+
+ 'Only valid for outgoing calls.',
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
if (this.__status !== 'calling' && this.__status !== 'ringing') {
|
|
199
|
+
throw new Error(
|
|
200
|
+
`Call.cancel() invalid: status=${this.__status}. `
|
|
201
|
+
+ 'Only valid when status is calling or ringing.',
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
try {
|
|
205
|
+
await this.wechaty.puppet.callCancel(this.id)
|
|
206
|
+
} finally {
|
|
207
|
+
this.__finalize()
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Hang up a connected call. Only valid when status=connected.
|
|
213
|
+
*/
|
|
214
|
+
async hangup (reason?: string): Promise<void> {
|
|
215
|
+
if (this.__status !== 'connected') {
|
|
216
|
+
throw new Error(
|
|
217
|
+
`Call.hangup() invalid: status=${this.__status}. `
|
|
218
|
+
+ 'Only valid for connected calls.',
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
await this.wechaty.puppet.callHangup(this.id, reason)
|
|
223
|
+
} finally {
|
|
224
|
+
this.__finalize()
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Invite additional contacts to a live call (group-call growth).
|
|
230
|
+
*/
|
|
231
|
+
async add (contacts: ContactInterface[]): Promise<void> {
|
|
232
|
+
if (this.__status === 'ended') {
|
|
233
|
+
throw new Error('Call.add() invalid: status=ended. Cannot add to a finished call.')
|
|
234
|
+
}
|
|
235
|
+
if (contacts.length === 0) {
|
|
236
|
+
throw new Error('Call.add() requires at least one contact.')
|
|
237
|
+
}
|
|
238
|
+
await this.wechaty.puppet.callAdd(this.id, contacts.map(c => c.id))
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Pull a fresh admission ticket to the direct-to-gateway media path.
|
|
243
|
+
* Not cached: the credential is short-lived and may pre-allocate a session.
|
|
244
|
+
*/
|
|
245
|
+
async mediaEndpoint (): Promise<PUPPET.payloads.CallMediaEndpoint> {
|
|
246
|
+
return this.wechaty.puppet.callMediaEndpoint(this.id)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
// Framework-internal — must not be called from user code.
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Process an inbound call signal from the puppet layer.
|
|
255
|
+
* Drives state transitions and emits the corresponding object-level event.
|
|
256
|
+
* Terminal lifecycle (ended) is owned by the puppet-mixin which calls
|
|
257
|
+
* `__markEnded()` after observing the protocol-side endTime.
|
|
258
|
+
*/
|
|
259
|
+
__handleSignal (
|
|
260
|
+
signal: PUPPET.types.CallSignal,
|
|
261
|
+
actor: ContactInterface,
|
|
262
|
+
reason?: string,
|
|
263
|
+
): void {
|
|
264
|
+
if (this.__status === 'ended') {
|
|
265
|
+
log.warn('Call', '__handleSignal(%s) ignored in ended state for callId=%s', signal, this.id)
|
|
266
|
+
return
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
switch (signal) {
|
|
270
|
+
case PUPPET.types.CallSignal.Ringing:
|
|
271
|
+
if (this.__direction === 'outgoing' && this.__status === 'calling') {
|
|
272
|
+
this.__transitionTo('ringing')
|
|
273
|
+
this.emit('ringing')
|
|
274
|
+
}
|
|
275
|
+
break
|
|
276
|
+
|
|
277
|
+
case PUPPET.types.CallSignal.Accept:
|
|
278
|
+
if (this.__status === 'calling' || this.__status === 'ringing') {
|
|
279
|
+
this.__transitionTo('connected')
|
|
280
|
+
}
|
|
281
|
+
this.emit('accept', actor)
|
|
282
|
+
break
|
|
283
|
+
|
|
284
|
+
// Reject/Hangup emit BEFORE puppet-mixin's post-handler finalize, so
|
|
285
|
+
// consumers' handlers observe pre-terminal status. This is safe here
|
|
286
|
+
// because the callable terminal actions throw under their own guards at
|
|
287
|
+
// this moment (accept needs ringing, cancel needs calling/ringing,
|
|
288
|
+
// hangup needs connected). The Cancel case below is the asymmetric
|
|
289
|
+
// outlier: it MUST finalize first to close a media-acquisition race
|
|
290
|
+
// window on the receiving side.
|
|
291
|
+
case PUPPET.types.CallSignal.Reject:
|
|
292
|
+
this.emit('reject', actor, reason)
|
|
293
|
+
break
|
|
294
|
+
|
|
295
|
+
case PUPPET.types.CallSignal.Hangup:
|
|
296
|
+
this.emit('hangup', actor, reason)
|
|
297
|
+
break
|
|
298
|
+
|
|
299
|
+
case PUPPET.types.CallSignal.Cancel:
|
|
300
|
+
// Intentionally finalizes before emitting 'cancel' so consumers'
|
|
301
|
+
// cancel handler observes status === 'ended' synchronously. Cancel
|
|
302
|
+
// signals are always terminal on the receiving side (the caller has
|
|
303
|
+
// withdrawn), so the status flip should be visible to listeners
|
|
304
|
+
// before the descriptor event fires. The reverse order would let a
|
|
305
|
+
// cancel handler observe status === 'ringing' and, worse, call
|
|
306
|
+
// .accept() through the ringing guard before the puppet-mixin's
|
|
307
|
+
// post-emit finalize runs.
|
|
308
|
+
this.__finalize()
|
|
309
|
+
this.emit('cancel', reason)
|
|
310
|
+
break
|
|
311
|
+
|
|
312
|
+
default:
|
|
313
|
+
log.warn('Call', '__handleSignal() unhandled signal: %s', signal)
|
|
314
|
+
break
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Force the call into the ended terminal state and emit 'ended'.
|
|
320
|
+
* Called by puppet-mixin after the protocol-side endTime is observed.
|
|
321
|
+
* Kept as a named entry point distinct from {@link __finalize} so the
|
|
322
|
+
* puppet-mixin surface reads as intent ("the protocol says this is over").
|
|
323
|
+
* Idempotent via __finalize.
|
|
324
|
+
*/
|
|
325
|
+
__markEnded (): void {
|
|
326
|
+
this.__finalize()
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Single chokepoint for ending a call: flip the status to ended, emit
|
|
331
|
+
* the object-level 'ended' event, emit the bot-level 'call-ended' lifecycle
|
|
332
|
+
* event, and evict the call from the wechaty pool. Guarded by
|
|
333
|
+
* `__endedEmitted` so that overlapping local-control and puppet-echo paths
|
|
334
|
+
* cannot double-emit or double-evict.
|
|
335
|
+
*
|
|
336
|
+
* Why both event tiers fire here: 'call-ended' is a terminal lifecycle event
|
|
337
|
+
* (not an action event like 'call-reject'/'call-hangup'), and is independent
|
|
338
|
+
* of who initiated the termination. Local control paths (reject/cancel/
|
|
339
|
+
* hangup) and remote-driven paths (puppet signal echo via __finalizeIfEnded)
|
|
340
|
+
* must both surface it.
|
|
341
|
+
*/
|
|
342
|
+
private __finalize (): void {
|
|
343
|
+
if (this.__endedEmitted) {
|
|
344
|
+
return
|
|
345
|
+
}
|
|
346
|
+
this.__endedEmitted = true
|
|
347
|
+
this.__transitionTo('ended')
|
|
348
|
+
this.emit('ended')
|
|
349
|
+
;(this.wechaty as any).emit('call-ended', this as unknown as CallInterface)
|
|
350
|
+
;(this.wechaty as any).__callPool?.delete(this.id)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
private __transitionTo (nextStatus: CallStatus): void {
|
|
354
|
+
log.verbose('Call', '__transitionTo(%s) from %s', nextStatus, this.__status)
|
|
355
|
+
this.__status = nextStatus
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
class CallImplBase extends validationMixin(CallMixin)<CallImplInterface>() {}
|
|
361
|
+
interface CallImplInterface extends CallImplBase {}
|
|
362
|
+
|
|
363
|
+
type CallProtectedProperty =
|
|
364
|
+
| '__handleSignal'
|
|
365
|
+
| '__markEnded'
|
|
366
|
+
| '__finalize'
|
|
367
|
+
| '__endedEmitted'
|
|
368
|
+
|
|
369
|
+
type CallInterface = Omit<CallImplInterface, CallProtectedProperty>
|
|
370
|
+
class CallImpl extends validationMixin(CallImplBase)<CallInterface>() {}
|
|
371
|
+
|
|
372
|
+
type CallConstructor = Constructor<
|
|
373
|
+
CallInterface,
|
|
374
|
+
typeof CallImpl
|
|
375
|
+
>
|
|
376
|
+
|
|
377
|
+
export type {
|
|
378
|
+
CallConstructor,
|
|
379
|
+
CallInterface,
|
|
380
|
+
CallProtectedProperty,
|
|
381
|
+
}
|
|
382
|
+
export {
|
|
383
|
+
CallImpl,
|
|
384
|
+
}
|
|
385
|
+
|
|
13
386
|
class CallRecordMixin extends wechatifyMixinBase() {
|
|
14
387
|
|
|
15
388
|
static async create (): Promise<CallRecordInterface> {
|
|
@@ -52,6 +52,7 @@ import { stringifyFilter } from '../helper-functions/stringify-filter
|
|
|
52
52
|
import type { MessageInterface } from './message.js'
|
|
53
53
|
import type { TagInterface } from './tag.js'
|
|
54
54
|
import type { ContactSelfImpl } from './contact-self.js'
|
|
55
|
+
import type { CallInterface } from './call.js'
|
|
55
56
|
|
|
56
57
|
const MixinBase = wechatifyMixin(
|
|
57
58
|
poolifyMixin(
|
|
@@ -407,6 +408,23 @@ class ContactMixin extends MixinBase implements SayableSayer {
|
|
|
407
408
|
}
|
|
408
409
|
}
|
|
409
410
|
|
|
411
|
+
/**
|
|
412
|
+
* Initiate an outgoing 1-on-1 call to this contact.
|
|
413
|
+
*
|
|
414
|
+
* Syntactic sugar over `bot.call([this], options)`. Returns a Call object
|
|
415
|
+
* immediately (status: 'calling'); listen to call events for lifecycle updates.
|
|
416
|
+
*
|
|
417
|
+
* @example
|
|
418
|
+
* import * as PUPPET from '@juzi/wechaty-puppet'
|
|
419
|
+
* const call = await contact.call({ media: PUPPET.types.CallMediaType.Video })
|
|
420
|
+
* call.on('accept', actor => console.log('connected with', actor.name()))
|
|
421
|
+
* call.on('ended', () => console.log('call ended'))
|
|
422
|
+
*/
|
|
423
|
+
async call (options?: { media?: PUPPET.types.CallMediaType }): Promise<CallInterface> {
|
|
424
|
+
log.verbose('Contact', 'call(%s)', JSON.stringify(options ?? {}))
|
|
425
|
+
return (this.wechaty as any).call([ this as unknown as ContactInterface ], options)
|
|
426
|
+
}
|
|
427
|
+
|
|
410
428
|
/**
|
|
411
429
|
* Get the name from a contact
|
|
412
430
|
*
|
|
@@ -122,3 +122,92 @@ test('ProtectedProperties', async t => {
|
|
|
122
122
|
const noOneLeft: NotExistTest = true
|
|
123
123
|
t.ok(noOneLeft, 'should match Wechaty properties for every protected property')
|
|
124
124
|
})
|
|
125
|
+
|
|
126
|
+
test('batchSendMessage()', async t => {
|
|
127
|
+
const EXPECTED_BATCH_ID = 'batch-id-1'
|
|
128
|
+
const EXPECTED_TARGET_ID = 'contact-id-1'
|
|
129
|
+
const EXPECTED_TEXT = 'hello stable broadcast'
|
|
130
|
+
|
|
131
|
+
const sandbox = sinon.createSandbox()
|
|
132
|
+
|
|
133
|
+
const puppet = new PuppetMock() as any
|
|
134
|
+
puppet.messageBatchSendText = async () => undefined
|
|
135
|
+
|
|
136
|
+
const wechaty = WechatyBuilder.build({ puppet })
|
|
137
|
+
await wechaty.start()
|
|
138
|
+
|
|
139
|
+
const messageBatchSendTextStub = sandbox.stub(puppet, 'messageBatchSendText').resolves({
|
|
140
|
+
results: [ { conversationId: EXPECTED_TARGET_ID, id: 'message-id-1' } ],
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
const result = await wechaty.Message.batchSendMessage(
|
|
144
|
+
[ { id: EXPECTED_TARGET_ID } as any ],
|
|
145
|
+
{
|
|
146
|
+
payload: {
|
|
147
|
+
sayableList: [
|
|
148
|
+
PUPPET.payloads.sayable.text(EXPECTED_TEXT),
|
|
149
|
+
],
|
|
150
|
+
type: PUPPET.types.Post.Broadcast,
|
|
151
|
+
},
|
|
152
|
+
} as any,
|
|
153
|
+
EXPECTED_BATCH_ID,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
t.equal(messageBatchSendTextStub.callCount, 1, 'should call puppet batch text send once')
|
|
157
|
+
t.same(messageBatchSendTextStub.firstCall.args, [
|
|
158
|
+
[ EXPECTED_TARGET_ID ],
|
|
159
|
+
EXPECTED_TEXT,
|
|
160
|
+
EXPECTED_BATCH_ID,
|
|
161
|
+
], 'should pass target ids, text, and batch task id to puppet')
|
|
162
|
+
t.same(result, [
|
|
163
|
+
{
|
|
164
|
+
conversationId: EXPECTED_TARGET_ID,
|
|
165
|
+
id: 'message-id-1',
|
|
166
|
+
sayableIndex: 0,
|
|
167
|
+
},
|
|
168
|
+
], 'should return per-target batch send results')
|
|
169
|
+
|
|
170
|
+
sandbox.restore()
|
|
171
|
+
await wechaty.stop()
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
test('batchSendMessage() returns per-target batch errors', async t => {
|
|
175
|
+
const EXPECTED_BATCH_ID = 'batch-id-1'
|
|
176
|
+
const EXPECTED_TARGET_ID = 'contact-id-1'
|
|
177
|
+
|
|
178
|
+
const sandbox = sinon.createSandbox()
|
|
179
|
+
|
|
180
|
+
const puppet = new PuppetMock() as any
|
|
181
|
+
puppet.messageBatchSendText = async () => undefined
|
|
182
|
+
|
|
183
|
+
const wechaty = WechatyBuilder.build({ puppet })
|
|
184
|
+
await wechaty.start()
|
|
185
|
+
|
|
186
|
+
sandbox.stub(puppet, 'messageBatchSendText').resolves({
|
|
187
|
+
results: [ { conversationId: EXPECTED_TARGET_ID, error: 'boom' } ],
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
const result = await wechaty.Message.batchSendMessage(
|
|
191
|
+
[ { id: EXPECTED_TARGET_ID } as any ],
|
|
192
|
+
{
|
|
193
|
+
payload: {
|
|
194
|
+
sayableList: [
|
|
195
|
+
PUPPET.payloads.sayable.text('hello'),
|
|
196
|
+
],
|
|
197
|
+
type: PUPPET.types.Post.Broadcast,
|
|
198
|
+
},
|
|
199
|
+
} as any,
|
|
200
|
+
EXPECTED_BATCH_ID,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
t.same(result, [
|
|
204
|
+
{
|
|
205
|
+
conversationId: EXPECTED_TARGET_ID,
|
|
206
|
+
error: 'boom',
|
|
207
|
+
sayableIndex: 0,
|
|
208
|
+
},
|
|
209
|
+
], 'should return per-target batch send failures for callers to persist')
|
|
210
|
+
|
|
211
|
+
sandbox.restore()
|
|
212
|
+
await wechaty.stop()
|
|
213
|
+
})
|
|
@@ -19,8 +19,9 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { EventEmitter } from 'events'
|
|
21
21
|
import * as PUPPET from '@juzi/wechaty-puppet'
|
|
22
|
-
import
|
|
23
|
-
|
|
22
|
+
import {
|
|
23
|
+
FileBox,
|
|
24
|
+
type FileBoxInterface,
|
|
24
25
|
} from 'file-box'
|
|
25
26
|
|
|
26
27
|
import type { Constructor } from 'clone-class'
|
|
@@ -88,6 +89,33 @@ import type { ChatHistoryInterface } from './chat-history.js'
|
|
|
88
89
|
import type { WxxdProductInterface } from './wxxd-product.js'
|
|
89
90
|
import type { WxxdOrderInterface } from './wxxd-order.js'
|
|
90
91
|
|
|
92
|
+
type BatchSendResult = {
|
|
93
|
+
conversationId?: string,
|
|
94
|
+
error?: string,
|
|
95
|
+
id?: string,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
type BatchSendResponse = {
|
|
99
|
+
results?: BatchSendResult[],
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
type BatchSendMessageResult = BatchSendResult & {
|
|
103
|
+
sayableIndex: number,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const fileBoxFromPayload = (filebox: string | FileBoxInterface): FileBoxInterface =>
|
|
107
|
+
typeof filebox === 'string'
|
|
108
|
+
? FileBox.fromJSON(filebox)
|
|
109
|
+
: filebox
|
|
110
|
+
|
|
111
|
+
const getBatchSendResults = (methodName: string, response?: BatchSendResponse): BatchSendResult[] => {
|
|
112
|
+
const results = response?.results
|
|
113
|
+
if (!Array.isArray(results)) {
|
|
114
|
+
throw new Error(`${methodName} returned invalid batch send response`)
|
|
115
|
+
}
|
|
116
|
+
return results
|
|
117
|
+
}
|
|
118
|
+
|
|
91
119
|
const MixinBase = wechatifyMixin(
|
|
92
120
|
EventEmitter,
|
|
93
121
|
)
|
|
@@ -304,6 +332,115 @@ class MessageMixin extends MixinBase implements SayableSayer {
|
|
|
304
332
|
}
|
|
305
333
|
}
|
|
306
334
|
|
|
335
|
+
static async batchSendMessage (targets: (ContactInterface | RoomInterface)[], post: PostInterface, sendBatchId: string): Promise<BatchSendMessageResult[]> {
|
|
336
|
+
log.verbose('Message', 'static batchSendMessage()')
|
|
337
|
+
|
|
338
|
+
const targetIds = targets.map(target => target.id)
|
|
339
|
+
|
|
340
|
+
if (!PUPPET.payloads.isPostClient(post.payload)) {
|
|
341
|
+
throw new Error('you cannot batch send message with a server post payload')
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const puppet = this.wechaty.puppet
|
|
345
|
+
|
|
346
|
+
const batchSendResults: BatchSendMessageResult[] = []
|
|
347
|
+
|
|
348
|
+
for (const [ sayableIndex, sayable ] of post.payload.sayableList.entries()) {
|
|
349
|
+
let methodName = ''
|
|
350
|
+
let response: BatchSendResponse | undefined
|
|
351
|
+
|
|
352
|
+
switch (sayable.type) {
|
|
353
|
+
case PUPPET.types.Sayable.Text:
|
|
354
|
+
methodName = 'messageBatchSendText'
|
|
355
|
+
response = await puppet.messageBatchSendText(
|
|
356
|
+
targetIds,
|
|
357
|
+
sayable.payload.text,
|
|
358
|
+
sendBatchId,
|
|
359
|
+
)
|
|
360
|
+
break
|
|
361
|
+
|
|
362
|
+
case PUPPET.types.Sayable.Attachment:
|
|
363
|
+
case PUPPET.types.Sayable.Audio:
|
|
364
|
+
case PUPPET.types.Sayable.Emoticon:
|
|
365
|
+
case PUPPET.types.Sayable.Image:
|
|
366
|
+
case PUPPET.types.Sayable.Video:
|
|
367
|
+
methodName = 'messageBatchSendFile'
|
|
368
|
+
response = await puppet.messageBatchSendFile(
|
|
369
|
+
targetIds,
|
|
370
|
+
fileBoxFromPayload(sayable.payload.filebox),
|
|
371
|
+
sendBatchId,
|
|
372
|
+
)
|
|
373
|
+
break
|
|
374
|
+
|
|
375
|
+
case PUPPET.types.Sayable.Contact:
|
|
376
|
+
methodName = 'messageBatchSendContact'
|
|
377
|
+
response = await puppet.messageBatchSendContact(
|
|
378
|
+
targetIds,
|
|
379
|
+
sayable.payload.contactId,
|
|
380
|
+
sendBatchId,
|
|
381
|
+
)
|
|
382
|
+
break
|
|
383
|
+
|
|
384
|
+
case PUPPET.types.Sayable.Url:
|
|
385
|
+
methodName = 'messageBatchSendUrl'
|
|
386
|
+
response = await puppet.messageBatchSendUrl(
|
|
387
|
+
targetIds,
|
|
388
|
+
sayable.payload,
|
|
389
|
+
sendBatchId,
|
|
390
|
+
)
|
|
391
|
+
break
|
|
392
|
+
|
|
393
|
+
case PUPPET.types.Sayable.MiniProgram:
|
|
394
|
+
methodName = 'messageBatchSendMiniProgram'
|
|
395
|
+
response = await puppet.messageBatchSendMiniProgram(
|
|
396
|
+
targetIds,
|
|
397
|
+
sayable.payload,
|
|
398
|
+
sendBatchId,
|
|
399
|
+
)
|
|
400
|
+
break
|
|
401
|
+
|
|
402
|
+
case PUPPET.types.Sayable.Location:
|
|
403
|
+
methodName = 'messageBatchSendLocation'
|
|
404
|
+
response = await puppet.messageBatchSendLocation(
|
|
405
|
+
targetIds,
|
|
406
|
+
sayable.payload,
|
|
407
|
+
sendBatchId,
|
|
408
|
+
)
|
|
409
|
+
break
|
|
410
|
+
|
|
411
|
+
case PUPPET.types.Sayable.Channel:
|
|
412
|
+
methodName = 'messageBatchSendChannel'
|
|
413
|
+
response = await puppet.messageBatchSendChannel(
|
|
414
|
+
targetIds,
|
|
415
|
+
sayable.payload,
|
|
416
|
+
sendBatchId,
|
|
417
|
+
)
|
|
418
|
+
break
|
|
419
|
+
|
|
420
|
+
case PUPPET.types.Sayable.ChannelCard:
|
|
421
|
+
methodName = 'messageBatchSendChannelCard'
|
|
422
|
+
response = await puppet.messageBatchSendChannelCard(
|
|
423
|
+
targetIds,
|
|
424
|
+
sayable.payload,
|
|
425
|
+
sendBatchId,
|
|
426
|
+
)
|
|
427
|
+
break
|
|
428
|
+
|
|
429
|
+
default:
|
|
430
|
+
throw new Error(`batch send message does not support sayable type ${PUPPET.types.Sayable[sayable.type]}`)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
batchSendResults.push(
|
|
434
|
+
...getBatchSendResults(methodName, response).map(result => ({
|
|
435
|
+
...result,
|
|
436
|
+
sayableIndex,
|
|
437
|
+
})),
|
|
438
|
+
)
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return batchSendResults
|
|
442
|
+
}
|
|
443
|
+
|
|
307
444
|
static async getBroadcastStatus (broadcast: PostInterface): Promise<{
|
|
308
445
|
status: PUPPET.types.BroadcastStatus,
|
|
309
446
|
detail: {
|
package/src/user-modules/mod.ts
CHANGED
|
@@ -123,6 +123,9 @@ import {
|
|
|
123
123
|
PremiumOnlineAppointmentCardConstructor,
|
|
124
124
|
} from './premium-online-appointment-card.js'
|
|
125
125
|
import {
|
|
126
|
+
CallImpl,
|
|
127
|
+
CallInterface,
|
|
128
|
+
CallConstructor,
|
|
126
129
|
CallRecordImpl,
|
|
127
130
|
CallRecordInterface,
|
|
128
131
|
CallRecordConstructor,
|
|
@@ -161,6 +164,12 @@ import {
|
|
|
161
164
|
import { wechatifyUserModule } from '../user-mixins/wechatify.js'
|
|
162
165
|
|
|
163
166
|
export type {
|
|
167
|
+
CallStatus,
|
|
168
|
+
CallDirection,
|
|
169
|
+
} from './call.js'
|
|
170
|
+
|
|
171
|
+
export type {
|
|
172
|
+
CallInterface,
|
|
164
173
|
ContactInterface,
|
|
165
174
|
ContactSelfInterface,
|
|
166
175
|
FavoriteInterface,
|
|
@@ -192,6 +201,7 @@ export type {
|
|
|
192
201
|
}
|
|
193
202
|
|
|
194
203
|
export type {
|
|
204
|
+
CallConstructor,
|
|
195
205
|
ContactConstructor,
|
|
196
206
|
ContactSelfConstructor,
|
|
197
207
|
FavoriteConstructor,
|
|
@@ -225,6 +235,7 @@ export type {
|
|
|
225
235
|
export {
|
|
226
236
|
wechatifyUserModule,
|
|
227
237
|
|
|
238
|
+
CallImpl,
|
|
228
239
|
ContactImpl,
|
|
229
240
|
ContactSelfImpl,
|
|
230
241
|
FavoriteImpl,
|