@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.
Files changed (145) hide show
  1. package/dist/cjs/src/mods/impls.d.ts +2 -2
  2. package/dist/cjs/src/mods/impls.d.ts.map +1 -1
  3. package/dist/cjs/src/mods/impls.js +2 -1
  4. package/dist/cjs/src/mods/impls.js.map +1 -1
  5. package/dist/cjs/src/mods/users.d.ts +2 -1
  6. package/dist/cjs/src/mods/users.d.ts.map +1 -1
  7. package/dist/cjs/src/package-json.js +4 -4
  8. package/dist/cjs/src/package-json.js.map +1 -1
  9. package/dist/cjs/src/schemas/call-events.d.ts +20 -0
  10. package/dist/cjs/src/schemas/call-events.d.ts.map +1 -0
  11. package/dist/cjs/src/schemas/call-events.js +7 -0
  12. package/dist/cjs/src/schemas/call-events.js.map +1 -0
  13. package/dist/cjs/src/schemas/mod.d.ts +3 -2
  14. package/dist/cjs/src/schemas/mod.d.ts.map +1 -1
  15. package/dist/cjs/src/schemas/mod.js +3 -1
  16. package/dist/cjs/src/schemas/mod.js.map +1 -1
  17. package/dist/cjs/src/schemas/wechaty-events.d.ts +23 -2
  18. package/dist/cjs/src/schemas/wechaty-events.d.ts.map +1 -1
  19. package/dist/cjs/src/schemas/wechaty-events.js +6 -0
  20. package/dist/cjs/src/schemas/wechaty-events.js.map +1 -1
  21. package/dist/cjs/src/user-modules/call.d.ts +158 -0
  22. package/dist/cjs/src/user-modules/call.d.ts.map +1 -1
  23. package/dist/cjs/src/user-modules/call.js +289 -1
  24. package/dist/cjs/src/user-modules/call.js.map +1 -1
  25. package/dist/cjs/src/user-modules/call.spec.d.ts +7 -0
  26. package/dist/cjs/src/user-modules/call.spec.d.ts.map +1 -0
  27. package/dist/cjs/src/user-modules/call.spec.js +759 -0
  28. package/dist/cjs/src/user-modules/call.spec.js.map +1 -0
  29. package/dist/cjs/src/user-modules/contact.d.ts +16 -0
  30. package/dist/cjs/src/user-modules/contact.d.ts.map +1 -1
  31. package/dist/cjs/src/user-modules/contact.js +16 -0
  32. package/dist/cjs/src/user-modules/contact.js.map +1 -1
  33. package/dist/cjs/src/user-modules/message.d.ts +10 -1
  34. package/dist/cjs/src/user-modules/message.d.ts.map +1 -1
  35. package/dist/cjs/src/user-modules/message.js +69 -0
  36. package/dist/cjs/src/user-modules/message.js.map +1 -1
  37. package/dist/cjs/src/user-modules/message.spec.js +65 -0
  38. package/dist/cjs/src/user-modules/message.spec.js.map +1 -1
  39. package/dist/cjs/src/user-modules/mod.d.ts +5 -4
  40. package/dist/cjs/src/user-modules/mod.d.ts.map +1 -1
  41. package/dist/cjs/src/user-modules/mod.js +2 -1
  42. package/dist/cjs/src/user-modules/mod.js.map +1 -1
  43. package/dist/cjs/src/wechaty/wechaty-base.d.ts +66 -7
  44. package/dist/cjs/src/wechaty/wechaty-base.d.ts.map +1 -1
  45. package/dist/cjs/src/wechaty/wechaty-base.js +28 -0
  46. package/dist/cjs/src/wechaty/wechaty-base.js.map +1 -1
  47. package/dist/cjs/src/wechaty/wechaty-impl.spec.js +10 -8
  48. package/dist/cjs/src/wechaty/wechaty-impl.spec.js.map +1 -1
  49. package/dist/cjs/src/wechaty-mixins/gerror-mixin.d.ts +1 -1
  50. package/dist/cjs/src/wechaty-mixins/io-mixin.d.ts +2 -2
  51. package/dist/cjs/src/wechaty-mixins/login-mixin.d.ts +15 -5
  52. package/dist/cjs/src/wechaty-mixins/login-mixin.d.ts.map +1 -1
  53. package/dist/cjs/src/wechaty-mixins/misc-mixin.d.ts +15 -5
  54. package/dist/cjs/src/wechaty-mixins/misc-mixin.d.ts.map +1 -1
  55. package/dist/cjs/src/wechaty-mixins/plugin-mixin.d.ts +32 -6
  56. package/dist/cjs/src/wechaty-mixins/plugin-mixin.d.ts.map +1 -1
  57. package/dist/cjs/src/wechaty-mixins/puppet-mixin.d.ts +39 -5
  58. package/dist/cjs/src/wechaty-mixins/puppet-mixin.d.ts.map +1 -1
  59. package/dist/cjs/src/wechaty-mixins/puppet-mixin.js +149 -0
  60. package/dist/cjs/src/wechaty-mixins/puppet-mixin.js.map +1 -1
  61. package/dist/cjs/src/wechaty-mixins/wechatify-user-module-mixin.d.ts +5 -3
  62. package/dist/cjs/src/wechaty-mixins/wechatify-user-module-mixin.d.ts.map +1 -1
  63. package/dist/cjs/src/wechaty-mixins/wechatify-user-module-mixin.js +3 -0
  64. package/dist/cjs/src/wechaty-mixins/wechatify-user-module-mixin.js.map +1 -1
  65. package/dist/esm/src/mods/impls.d.ts +2 -2
  66. package/dist/esm/src/mods/impls.d.ts.map +1 -1
  67. package/dist/esm/src/mods/impls.js +1 -1
  68. package/dist/esm/src/mods/impls.js.map +1 -1
  69. package/dist/esm/src/mods/users.d.ts +2 -1
  70. package/dist/esm/src/mods/users.d.ts.map +1 -1
  71. package/dist/esm/src/package-json.js +4 -4
  72. package/dist/esm/src/package-json.js.map +1 -1
  73. package/dist/esm/src/schemas/call-events.d.ts +20 -0
  74. package/dist/esm/src/schemas/call-events.d.ts.map +1 -0
  75. package/dist/esm/src/schemas/call-events.js +4 -0
  76. package/dist/esm/src/schemas/call-events.js.map +1 -0
  77. package/dist/esm/src/schemas/mod.d.ts +3 -2
  78. package/dist/esm/src/schemas/mod.d.ts.map +1 -1
  79. package/dist/esm/src/schemas/mod.js +2 -1
  80. package/dist/esm/src/schemas/mod.js.map +1 -1
  81. package/dist/esm/src/schemas/wechaty-events.d.ts +23 -2
  82. package/dist/esm/src/schemas/wechaty-events.d.ts.map +1 -1
  83. package/dist/esm/src/schemas/wechaty-events.js +6 -0
  84. package/dist/esm/src/schemas/wechaty-events.js.map +1 -1
  85. package/dist/esm/src/user-modules/call.d.ts +158 -0
  86. package/dist/esm/src/user-modules/call.d.ts.map +1 -1
  87. package/dist/esm/src/user-modules/call.js +289 -1
  88. package/dist/esm/src/user-modules/call.js.map +1 -1
  89. package/dist/esm/src/user-modules/call.spec.d.ts +7 -0
  90. package/dist/esm/src/user-modules/call.spec.d.ts.map +1 -0
  91. package/dist/esm/src/user-modules/call.spec.js +734 -0
  92. package/dist/esm/src/user-modules/call.spec.js.map +1 -0
  93. package/dist/esm/src/user-modules/contact.d.ts +16 -0
  94. package/dist/esm/src/user-modules/contact.d.ts.map +1 -1
  95. package/dist/esm/src/user-modules/contact.js +16 -0
  96. package/dist/esm/src/user-modules/contact.js.map +1 -1
  97. package/dist/esm/src/user-modules/message.d.ts +10 -1
  98. package/dist/esm/src/user-modules/message.d.ts.map +1 -1
  99. package/dist/esm/src/user-modules/message.js +69 -0
  100. package/dist/esm/src/user-modules/message.js.map +1 -1
  101. package/dist/esm/src/user-modules/message.spec.js +65 -0
  102. package/dist/esm/src/user-modules/message.spec.js.map +1 -1
  103. package/dist/esm/src/user-modules/mod.d.ts +5 -4
  104. package/dist/esm/src/user-modules/mod.d.ts.map +1 -1
  105. package/dist/esm/src/user-modules/mod.js +2 -2
  106. package/dist/esm/src/user-modules/mod.js.map +1 -1
  107. package/dist/esm/src/wechaty/wechaty-base.d.ts +66 -7
  108. package/dist/esm/src/wechaty/wechaty-base.d.ts.map +1 -1
  109. package/dist/esm/src/wechaty/wechaty-base.js +28 -0
  110. package/dist/esm/src/wechaty/wechaty-base.js.map +1 -1
  111. package/dist/esm/src/wechaty/wechaty-impl.spec.js +10 -8
  112. package/dist/esm/src/wechaty/wechaty-impl.spec.js.map +1 -1
  113. package/dist/esm/src/wechaty-mixins/gerror-mixin.d.ts +1 -1
  114. package/dist/esm/src/wechaty-mixins/io-mixin.d.ts +2 -2
  115. package/dist/esm/src/wechaty-mixins/login-mixin.d.ts +15 -5
  116. package/dist/esm/src/wechaty-mixins/login-mixin.d.ts.map +1 -1
  117. package/dist/esm/src/wechaty-mixins/misc-mixin.d.ts +15 -5
  118. package/dist/esm/src/wechaty-mixins/misc-mixin.d.ts.map +1 -1
  119. package/dist/esm/src/wechaty-mixins/plugin-mixin.d.ts +32 -6
  120. package/dist/esm/src/wechaty-mixins/plugin-mixin.d.ts.map +1 -1
  121. package/dist/esm/src/wechaty-mixins/puppet-mixin.d.ts +39 -5
  122. package/dist/esm/src/wechaty-mixins/puppet-mixin.d.ts.map +1 -1
  123. package/dist/esm/src/wechaty-mixins/puppet-mixin.js +149 -0
  124. package/dist/esm/src/wechaty-mixins/puppet-mixin.js.map +1 -1
  125. package/dist/esm/src/wechaty-mixins/wechatify-user-module-mixin.d.ts +5 -3
  126. package/dist/esm/src/wechaty-mixins/wechatify-user-module-mixin.d.ts.map +1 -1
  127. package/dist/esm/src/wechaty-mixins/wechatify-user-module-mixin.js +4 -1
  128. package/dist/esm/src/wechaty-mixins/wechatify-user-module-mixin.js.map +1 -1
  129. package/package.json +3 -3
  130. package/src/mods/impls.ts +2 -0
  131. package/src/mods/users.ts +6 -0
  132. package/src/package-json.ts +4 -4
  133. package/src/schemas/call-events.ts +35 -0
  134. package/src/schemas/mod.ts +6 -0
  135. package/src/schemas/wechaty-events.ts +28 -0
  136. package/src/user-modules/call.spec.ts +929 -0
  137. package/src/user-modules/call.ts +373 -0
  138. package/src/user-modules/contact.ts +18 -0
  139. package/src/user-modules/message.spec.ts +89 -0
  140. package/src/user-modules/message.ts +139 -2
  141. package/src/user-modules/mod.ts +11 -0
  142. package/src/wechaty/wechaty-base.ts +40 -1
  143. package/src/wechaty/wechaty-impl.spec.ts +4 -0
  144. package/src/wechaty-mixins/puppet-mixin.ts +184 -0
  145. package/src/wechaty-mixins/wechatify-user-module-mixin.ts +6 -0
@@ -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 type {
23
- FileBoxInterface,
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: {
@@ -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,