@juzi/wechaty 1.0.161 → 1.0.163

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.
@@ -931,3 +931,206 @@ test('dirty(Call) refreshes user-layer payload so getters see new value', async
931
931
  await wechaty.stop()
932
932
  sandbox.restore()
933
933
  })
934
+
935
+ // ---------------------------------------------------------------------------
936
+ // 12. end() — universal terminator dispatches by state
937
+ // ---------------------------------------------------------------------------
938
+
939
+ test('call.end() on outgoing calling invokes puppet.callCancel and ends', async t => {
940
+ const { puppet, wechaty } = buildWechaty()
941
+ await startAndLogin(puppet, wechaty)
942
+
943
+ puppet.callInvite = sandbox.stub().resolves('call-id-end-cancel')
944
+ stubCallPayload(puppet, (id: string) => ({
945
+ id,
946
+ starter : 'bot-self',
947
+ participants : [ 'bot-self', 'peer' ],
948
+ media : PUPPET.types.CallMediaType.Audio,
949
+ startTime : 1,
950
+ }))
951
+ const cancelStub = sandbox.stub().resolves(undefined)
952
+ const hangupStub = sandbox.stub().resolves(undefined)
953
+ puppet.callCancel = cancelStub
954
+ puppet.callHangup = hangupStub
955
+
956
+ const contact = (wechaty.Contact as typeof ContactImpl).load('peer')
957
+ const call = await contact.call()
958
+ t.equal(call.status(), 'calling', 'prerequisite: outgoing call starts as calling')
959
+
960
+ await call.end('demo')
961
+ t.same(cancelStub.firstCall.args, [ call.id ], 'callCancel should be invoked with callId')
962
+ t.equal(hangupStub.callCount, 0, 'callHangup should not be touched')
963
+ t.equal(call.status(), 'ended', 'status should be ended')
964
+ t.equal((wechaty as any).__callPool.has(call.id), false, 'pool should be evicted')
965
+
966
+ await call.end('again')
967
+ t.equal(cancelStub.callCount, 1, 'second end() should be a no-op')
968
+
969
+ await wechaty.stop()
970
+ sandbox.restore()
971
+ })
972
+
973
+ test('call.end() on connected call invokes puppet.callHangup with reason', async t => {
974
+ const { puppet, wechaty } = buildWechaty()
975
+ await startAndLogin(puppet, wechaty)
976
+
977
+ puppet.callInvite = sandbox.stub().resolves('call-id-end-hangup')
978
+ stubCallPayload(puppet, (id: string) => ({
979
+ id,
980
+ starter : 'bot-self',
981
+ participants : [ 'bot-self', 'peer' ],
982
+ media : PUPPET.types.CallMediaType.Audio,
983
+ startTime : 1,
984
+ }))
985
+ const cancelStub = sandbox.stub().resolves(undefined)
986
+ const hangupStub = sandbox.stub().resolves(undefined)
987
+ puppet.callCancel = cancelStub
988
+ puppet.callHangup = hangupStub
989
+
990
+ const contact = (wechaty.Contact as typeof ContactImpl).load('peer')
991
+ const call = await contact.call()
992
+
993
+ ;(puppet as any).emit('call', {
994
+ callId : call.id,
995
+ signal : PUPPET.types.CallSignal.Accept,
996
+ contactId : 'peer',
997
+ timestamp : 2,
998
+ } as PUPPET.payloads.EventCall)
999
+ await flush()
1000
+ t.equal(call.status(), 'connected', 'prerequisite: call should be connected')
1001
+
1002
+ await call.end('bye')
1003
+ t.same(hangupStub.firstCall.args, [ call.id, 'bye' ], 'callHangup args should be [callId, reason]')
1004
+ t.equal(cancelStub.callCount, 0, 'callCancel should not be touched')
1005
+ t.equal(call.status(), 'ended', 'status should be ended')
1006
+
1007
+ await wechaty.stop()
1008
+ sandbox.restore()
1009
+ })
1010
+
1011
+ test('call.end() on incoming ringing invokes puppet.callReject with reason', async t => {
1012
+ const { puppet, wechaty } = buildWechaty()
1013
+ await startAndLogin(puppet, wechaty)
1014
+
1015
+ const CALL_ID = 'call-id-end-reject'
1016
+ stubCallPayload(puppet, (id: string) => ({
1017
+ id,
1018
+ starter : 'peer',
1019
+ participants : [ 'peer', 'bot-self' ],
1020
+ media : PUPPET.types.CallMediaType.Audio,
1021
+ startTime : 1,
1022
+ }))
1023
+ const rejectStub = sandbox.stub().resolves(undefined)
1024
+ puppet.callReject = rejectStub
1025
+
1026
+ let incoming: CallInterface | undefined
1027
+ ;(wechaty as any).on('call', (c: CallInterface) => { incoming = c })
1028
+ ;(puppet as any).emit('call', {
1029
+ callId : CALL_ID,
1030
+ signal : PUPPET.types.CallSignal.Invite,
1031
+ contactId : 'peer',
1032
+ timestamp : 1,
1033
+ } as PUPPET.payloads.EventCall)
1034
+ await flush()
1035
+ t.ok(incoming, 'prerequisite: incoming call should surface via bot.on(call)')
1036
+ t.equal(incoming!.status(), 'ringing', 'prerequisite: incoming call should be ringing')
1037
+
1038
+ await incoming!.end('busy')
1039
+ t.same(rejectStub.firstCall.args, [ CALL_ID, 'busy' ], 'callReject args should be [callId, reason]')
1040
+ t.equal(incoming!.status(), 'ended', 'status should be ended')
1041
+
1042
+ await wechaty.stop()
1043
+ sandbox.restore()
1044
+ })
1045
+
1046
+ test('call.end() falls back to callHangup when callCancel is UNIMPLEMENTED', async t => {
1047
+ const { puppet, wechaty } = buildWechaty()
1048
+ await startAndLogin(puppet, wechaty)
1049
+
1050
+ puppet.callInvite = sandbox.stub().resolves('call-id-end-fallback')
1051
+ stubCallPayload(puppet, (id: string) => ({
1052
+ id,
1053
+ starter : 'bot-self',
1054
+ participants : [ 'bot-self', 'peer' ],
1055
+ media : PUPPET.types.CallMediaType.Audio,
1056
+ startTime : 1,
1057
+ }))
1058
+ const cancelStub = sandbox.stub().rejects(new Error('12 UNIMPLEMENTED: CallCancel is not supported'))
1059
+ const hangupStub = sandbox.stub().resolves(undefined)
1060
+ puppet.callCancel = cancelStub
1061
+ puppet.callHangup = hangupStub
1062
+
1063
+ const contact = (wechaty.Contact as typeof ContactImpl).load('peer')
1064
+ const call = await contact.call()
1065
+
1066
+ await call.end('timeout')
1067
+ t.equal(cancelStub.callCount, 1, 'callCancel should be attempted first')
1068
+ t.same(hangupStub.firstCall.args, [ call.id, 'timeout' ], 'should retry via callHangup')
1069
+ t.equal(call.status(), 'ended', 'status should be ended')
1070
+
1071
+ await wechaty.stop()
1072
+ sandbox.restore()
1073
+ })
1074
+
1075
+ test('call.end() retries via callHangup when peer accepts during in-flight cancel', async t => {
1076
+ const { puppet, wechaty } = buildWechaty()
1077
+ await startAndLogin(puppet, wechaty)
1078
+
1079
+ puppet.callInvite = sandbox.stub().resolves('call-id-end-race')
1080
+ stubCallPayload(puppet, (id: string) => ({
1081
+ id,
1082
+ starter : 'bot-self',
1083
+ participants : [ 'bot-self', 'peer' ],
1084
+ media : PUPPET.types.CallMediaType.Audio,
1085
+ startTime : 1,
1086
+ }))
1087
+ const hangupStub = sandbox.stub().resolves(undefined)
1088
+ puppet.callHangup = hangupStub
1089
+ puppet.callCancel = sandbox.stub().callsFake(async (callId: string) => {
1090
+ ;(puppet as any).emit('call', {
1091
+ callId,
1092
+ signal : PUPPET.types.CallSignal.Accept,
1093
+ contactId : 'peer',
1094
+ timestamp : 2,
1095
+ } as PUPPET.payloads.EventCall)
1096
+ await flush()
1097
+ throw new Error('call already answered')
1098
+ })
1099
+
1100
+ const contact = (wechaty.Contact as typeof ContactImpl).load('peer')
1101
+ const call = await contact.call()
1102
+
1103
+ await call.end('too late to cancel')
1104
+ t.same(hangupStub.firstCall.args, [ call.id, 'too late to cancel' ], 'should retry via callHangup after accept race')
1105
+ t.equal(call.status(), 'ended', 'status should be ended')
1106
+
1107
+ await wechaty.stop()
1108
+ sandbox.restore()
1109
+ })
1110
+
1111
+ test('call.end() rethrows non-recoverable pre-connect failures but still finalizes', async t => {
1112
+ const { puppet, wechaty } = buildWechaty()
1113
+ await startAndLogin(puppet, wechaty)
1114
+
1115
+ puppet.callInvite = sandbox.stub().resolves('call-id-end-fatal')
1116
+ stubCallPayload(puppet, (id: string) => ({
1117
+ id,
1118
+ starter : 'bot-self',
1119
+ participants : [ 'bot-self', 'peer' ],
1120
+ media : PUPPET.types.CallMediaType.Audio,
1121
+ startTime : 1,
1122
+ }))
1123
+ const hangupStub = sandbox.stub().resolves(undefined)
1124
+ puppet.callCancel = sandbox.stub().rejects(new Error('network error'))
1125
+ puppet.callHangup = hangupStub
1126
+
1127
+ const contact = (wechaty.Contact as typeof ContactImpl).load('peer')
1128
+ const call = await contact.call()
1129
+
1130
+ await t.rejects(call.end(), /network error/, 'end() should re-throw transport failures')
1131
+ t.equal(hangupStub.callCount, 0, 'no hangup retry for transport failures')
1132
+ t.equal(call.status(), 'ended', 'status should still finalize')
1133
+
1134
+ await wechaty.stop()
1135
+ sandbox.restore()
1136
+ })
@@ -29,6 +29,18 @@ interface CallConstructorOptions {
29
29
 
30
30
  const CallMixinBase = wechatifyMixin(CallEventEmitter)
31
31
 
32
+ /**
33
+ * Whether a puppet rejection means "this verb is not supported by the
34
+ * implementation" (gRPC UNIMPLEMENTED, or an explicit not-supported error),
35
+ * as opposed to a transport/business failure. Message-based on purpose:
36
+ * puppet errors cross the service boundary as GError and no structured
37
+ * status code survives the trip.
38
+ */
39
+ function isVerbUnsupportedError (e: unknown): boolean {
40
+ const message = e instanceof Error ? e.message : String(e)
41
+ return /UNIMPLEMENTED|not\s+(?:supported|implemented)|unsupported/i.test(message)
42
+ }
43
+
32
44
  /**
33
45
  * Call – a live, stateful call-control session abstraction.
34
46
  *
@@ -224,6 +236,65 @@ class CallMixin extends CallMixinBase {
224
236
  }
225
237
  }
226
238
 
239
+ /**
240
+ * Universal terminator (the "red button"): end the call from whatever
241
+ * state it is in, dispatching to the state-appropriate verb —
242
+ *
243
+ * connected → puppet.callHangup(reason)
244
+ * incoming + ringing → puppet.callReject(reason)
245
+ * outgoing + calling / ringing → puppet.callCancel()
246
+ * ended → no-op (idempotent)
247
+ *
248
+ * Unlike the precise verbs (cancel/reject/hangup), which throw on any state
249
+ * mismatch, end() absorbs the two failure modes inherent to check-then-act
250
+ * termination, retrying ONCE via callHangup when:
251
+ *
252
+ * - the peer accepts while our pre-connect verb is in flight (the verb
253
+ * comes back rejected but the session is now connected, so hangup is
254
+ * the correct verb by the time we retry), or
255
+ * - the puppet does not implement the chosen verb (protocols whose
256
+ * transport folds cancel/reject into a single hangup action).
257
+ *
258
+ * Local state always finalizes, matching cancel/reject/hangup semantics:
259
+ * whichever side actually terminated the session, the protocol-side record
260
+ * is the arbiter and its verdict arrives via the terminal signal path.
261
+ */
262
+ async end (reason?: string): Promise<void> {
263
+ if (this.__status === 'ended') {
264
+ this.log.verbose('Call', 'end() no-op: already ended, callId=%s', this.id)
265
+ return
266
+ }
267
+
268
+ try {
269
+ if (this.__status === 'connected') {
270
+ await this.wechaty.puppet.callHangup(this.id, reason)
271
+ return
272
+ }
273
+
274
+ try {
275
+ if (this.__direction === 'incoming') {
276
+ await this.wechaty.puppet.callReject(this.id, reason)
277
+ } else {
278
+ await this.wechaty.puppet.callCancel(this.id)
279
+ }
280
+ } catch (e) {
281
+ const acceptedMeanwhile = this.status() === 'connected'
282
+ if (!acceptedMeanwhile && !isVerbUnsupportedError(e)) {
283
+ throw e
284
+ }
285
+ this.log.verbose(
286
+ 'Call',
287
+ 'end() pre-connect verb failed (%s), retrying via callHangup, callId=%s',
288
+ acceptedMeanwhile ? 'accepted meanwhile' : 'verb unsupported',
289
+ this.id,
290
+ )
291
+ await this.wechaty.puppet.callHangup(this.id, reason)
292
+ }
293
+ } finally {
294
+ this.__finalize()
295
+ }
296
+ }
297
+
227
298
  /**
228
299
  * Invite additional contacts to a live call (group-call growth).
229
300
  */