@juzi/wechaty 1.0.150 → 1.0.156

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 (59) hide show
  1. package/dist/cjs/src/package-json.js +4 -4
  2. package/dist/cjs/src/user-modules/call.spec.js +5 -1
  3. package/dist/cjs/src/user-modules/call.spec.js.map +1 -1
  4. package/dist/cjs/src/user-modules/contact-alias-polling.spec.d.ts +3 -0
  5. package/dist/cjs/src/user-modules/contact-alias-polling.spec.d.ts.map +1 -0
  6. package/dist/cjs/src/user-modules/contact-alias-polling.spec.js +58 -0
  7. package/dist/cjs/src/user-modules/contact-alias-polling.spec.js.map +1 -0
  8. package/dist/cjs/src/user-modules/contact.d.ts.map +1 -1
  9. package/dist/cjs/src/user-modules/contact.js +14 -5
  10. package/dist/cjs/src/user-modules/contact.js.map +1 -1
  11. package/dist/cjs/src/user-modules/voice.d.ts +12 -9
  12. package/dist/cjs/src/user-modules/voice.d.ts.map +1 -1
  13. package/dist/cjs/src/user-modules/voice.js +63 -11
  14. package/dist/cjs/src/user-modules/voice.js.map +1 -1
  15. package/dist/cjs/src/user-modules/voice.spec.d.ts +3 -0
  16. package/dist/cjs/src/user-modules/voice.spec.d.ts.map +1 -0
  17. package/dist/cjs/src/user-modules/voice.spec.js +155 -0
  18. package/dist/cjs/src/user-modules/voice.spec.js.map +1 -0
  19. package/dist/cjs/src/wechaty-mixins/puppet-mixin-dirty-call.spec.d.ts +3 -0
  20. package/dist/cjs/src/wechaty-mixins/puppet-mixin-dirty-call.spec.d.ts.map +1 -0
  21. package/dist/cjs/src/wechaty-mixins/puppet-mixin-dirty-call.spec.js +68 -0
  22. package/dist/cjs/src/wechaty-mixins/puppet-mixin-dirty-call.spec.js.map +1 -0
  23. package/dist/cjs/src/wechaty-mixins/puppet-mixin.d.ts.map +1 -1
  24. package/dist/cjs/src/wechaty-mixins/puppet-mixin.js +14 -9
  25. package/dist/cjs/src/wechaty-mixins/puppet-mixin.js.map +1 -1
  26. package/dist/esm/src/package-json.js +4 -4
  27. package/dist/esm/src/user-modules/call.spec.js +5 -1
  28. package/dist/esm/src/user-modules/call.spec.js.map +1 -1
  29. package/dist/esm/src/user-modules/contact-alias-polling.spec.d.ts +3 -0
  30. package/dist/esm/src/user-modules/contact-alias-polling.spec.d.ts.map +1 -0
  31. package/dist/esm/src/user-modules/contact-alias-polling.spec.js +56 -0
  32. package/dist/esm/src/user-modules/contact-alias-polling.spec.js.map +1 -0
  33. package/dist/esm/src/user-modules/contact.d.ts.map +1 -1
  34. package/dist/esm/src/user-modules/contact.js +14 -5
  35. package/dist/esm/src/user-modules/contact.js.map +1 -1
  36. package/dist/esm/src/user-modules/voice.d.ts +12 -9
  37. package/dist/esm/src/user-modules/voice.d.ts.map +1 -1
  38. package/dist/esm/src/user-modules/voice.js +63 -11
  39. package/dist/esm/src/user-modules/voice.js.map +1 -1
  40. package/dist/esm/src/user-modules/voice.spec.d.ts +3 -0
  41. package/dist/esm/src/user-modules/voice.spec.d.ts.map +1 -0
  42. package/dist/esm/src/user-modules/voice.spec.js +153 -0
  43. package/dist/esm/src/user-modules/voice.spec.js.map +1 -0
  44. package/dist/esm/src/wechaty-mixins/puppet-mixin-dirty-call.spec.d.ts +3 -0
  45. package/dist/esm/src/wechaty-mixins/puppet-mixin-dirty-call.spec.d.ts.map +1 -0
  46. package/dist/esm/src/wechaty-mixins/puppet-mixin-dirty-call.spec.js +43 -0
  47. package/dist/esm/src/wechaty-mixins/puppet-mixin-dirty-call.spec.js.map +1 -0
  48. package/dist/esm/src/wechaty-mixins/puppet-mixin.d.ts.map +1 -1
  49. package/dist/esm/src/wechaty-mixins/puppet-mixin.js +14 -9
  50. package/dist/esm/src/wechaty-mixins/puppet-mixin.js.map +1 -1
  51. package/package.json +3 -3
  52. package/src/package-json.ts +4 -4
  53. package/src/user-modules/call.spec.ts +5 -1
  54. package/src/user-modules/contact-alias-polling.spec.ts +70 -0
  55. package/src/user-modules/contact.ts +14 -5
  56. package/src/user-modules/voice.spec.ts +187 -0
  57. package/src/user-modules/voice.ts +67 -12
  58. package/src/wechaty-mixins/puppet-mixin-dirty-call.spec.ts +55 -0
  59. package/src/wechaty-mixins/puppet-mixin.ts +14 -9
@@ -0,0 +1,187 @@
1
+ #!/usr/bin/env -S node --no-warnings --loader ts-node/esm
2
+ /**
3
+ * Wechaty Chatbot SDK - https://github.com/wechaty/wechaty
4
+ *
5
+ * @copyright 2016 Huan LI (李卓桓) <https://github.com/huan>, and
6
+ * Wechaty Contributors <https://github.com/wechaty>.
7
+ *
8
+ * Licensed under the Apache License, Version 2.0 (the "License");
9
+ * you may not use this file except in compliance with the License.
10
+ * You may obtain a copy of the License at
11
+ *
12
+ * http://www.apache.org/licenses/LICENSE-2.0
13
+ *
14
+ * Unless required by applicable law or agreed to in writing, software
15
+ * distributed under the License is distributed on an "AS IS" BASIS,
16
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17
+ * See the License for the specific language governing permissions and
18
+ * limitations under the License.
19
+ *
20
+ */
21
+ import {
22
+ test,
23
+ sinon,
24
+ } from 'tstest'
25
+
26
+ import { FileBox } from 'file-box'
27
+ import type * as PUPPET from '@juzi/wechaty-puppet'
28
+ import { PuppetMock } from '@juzi/wechaty-puppet-mock'
29
+ import { WechatyBuilder } from '../wechaty-builder.js'
30
+
31
+ const MESSAGE_ID = 'message-id-voice'
32
+
33
+ /**
34
+ * The fallback must trigger ONLY when the puppet does not implement the new
35
+ * RPC, never on transient failures: otherwise `text()` silently drops the
36
+ * `noSpeech` discriminator and makes the bot re-run its own paid ASR.
37
+ *
38
+ * The puppet is built & started once and reused across cases (`wechaty.Voice`
39
+ * is unavailable before `start()`). `messageVoice` / `messageVoiceText` are
40
+ * assigned directly (rather than via `sinon.stub(puppet, ...)`) because the
41
+ * installed wechaty-puppet-mock predates those methods, so stubbing a
42
+ * non-existent property would throw.
43
+ */
44
+ const puppet = new PuppetMock() as any
45
+ const wechaty = WechatyBuilder.build({ puppet })
46
+
47
+ let started = false
48
+ async function ensureStarted () {
49
+ if (!started) {
50
+ await wechaty.start()
51
+ started = true
52
+ }
53
+ }
54
+
55
+ const voice = () => wechaty.Voice.create(MESSAGE_ID)
56
+
57
+ const UNSUPPORTED = new Error('Wechaty Puppet Unsupported API Error.')
58
+
59
+ test('Voice.file() returns the dedicated messageVoice result when supported', async t => {
60
+ await ensureStarted()
61
+ puppet.messageVoice = sinon.stub().resolves(FileBox.fromBuffer(Buffer.from('voice'), 'voice.silk'))
62
+ puppet.messageFile = sinon.stub().resolves(FileBox.fromBuffer(Buffer.from('file'), 'file.bin'))
63
+
64
+ const fileBox = await voice().file()
65
+ t.equal(fileBox.name, 'voice.silk', 'should return the messageVoice FileBox')
66
+ t.ok(puppet.messageVoice.calledOnceWith(MESSAGE_ID), 'should call messageVoice')
67
+ t.notOk(puppet.messageFile.called, 'should NOT fall back to messageFile when supported')
68
+ })
69
+
70
+ test('Voice.file() falls back to messageFile on unsupported error', async t => {
71
+ await ensureStarted()
72
+ puppet.messageVoice = sinon.stub().rejects(UNSUPPORTED)
73
+ puppet.messageFile = sinon.stub().resolves(FileBox.fromBuffer(Buffer.from('file'), 'file.bin'))
74
+
75
+ const fileBox = await voice().file()
76
+ t.equal(fileBox.name, 'file.bin', 'should fall back to messageFile')
77
+ t.ok(puppet.messageFile.calledOnceWith(MESSAGE_ID), 'should call messageFile on fallback')
78
+ })
79
+
80
+ test('Voice.file() falls back to messageFile when messageVoice is absent (old puppet)', async t => {
81
+ await ensureStarted()
82
+ puppet.messageVoice = undefined // old puppet built without the method at all
83
+ puppet.messageFile = sinon.stub().resolves(FileBox.fromBuffer(Buffer.from('file'), 'file.bin'))
84
+
85
+ const fileBox = await voice().file()
86
+ t.equal(fileBox.name, 'file.bin', 'should fall back to messageFile via the typeof guard')
87
+ t.ok(puppet.messageFile.calledOnceWith(MESSAGE_ID), 'should call messageFile on fallback')
88
+ })
89
+
90
+ test('Voice.file() rethrows a transient error instead of falling back', async t => {
91
+ await ensureStarted()
92
+ puppet.messageVoice = sinon.stub().rejects(new Error('socket hang up'))
93
+ puppet.messageFile = sinon.stub().resolves(FileBox.fromBuffer(Buffer.from('file'), 'file.bin'))
94
+
95
+ await t.rejects(() => voice().file(), /socket hang up/, 'should rethrow the transient error')
96
+ t.notOk(puppet.messageFile.called, 'should NOT fall back on a transient error')
97
+ })
98
+
99
+ test('Voice.file() rethrows a genuine "is not a function" TypeError from a working messageVoice', async t => {
100
+ await ensureStarted()
101
+ // method exists but its implementation throws internally — must NOT be
102
+ // misclassified as unsupported just because the message says "is not a function"
103
+ puppet.messageVoice = sinon.stub().rejects(new TypeError('innerHelper.process is not a function'))
104
+ puppet.messageFile = sinon.stub().resolves(FileBox.fromBuffer(Buffer.from('file'), 'file.bin'))
105
+
106
+ await t.rejects(() => voice().file(), /is not a function/, 'should rethrow the internal TypeError')
107
+ t.notOk(puppet.messageFile.called, 'should NOT mask a real implementation bug')
108
+ })
109
+
110
+ test('Voice.text() returns the dedicated messageVoiceText payload when supported', async t => {
111
+ await ensureStarted()
112
+ puppet.messageVoiceText = sinon.stub().resolves({ text: 'hello', noSpeech: false })
113
+ puppet.messagePayload = sinon.stub().resolves({ text: 'legacy' } as PUPPET.payloads.Message)
114
+
115
+ const result = await voice().text()
116
+ t.same(result, { text: 'hello', noSpeech: false }, 'should return the messageVoiceText payload')
117
+ t.ok(puppet.messageVoiceText.calledOnceWith(MESSAGE_ID), 'should call messageVoiceText')
118
+ t.notOk(puppet.messagePayload.called, 'should NOT fall back to messagePayload when supported')
119
+ })
120
+
121
+ test('Voice.text() preserves noSpeech=true from the puppet (skip paid ASR)', async t => {
122
+ await ensureStarted()
123
+ puppet.messageVoiceText = sinon.stub().resolves({ text: '', noSpeech: true })
124
+ puppet.messagePayload = sinon.stub().resolves({ text: '' } as PUPPET.payloads.Message)
125
+
126
+ const result = await voice().text()
127
+ t.same(result, { text: '', noSpeech: true }, 'should return the puppet payload verbatim')
128
+ t.equal(result.noSpeech, true, 'should preserve noSpeech=true so the bot can skip its own ASR')
129
+ t.ok(puppet.messageVoiceText.calledOnceWith(MESSAGE_ID), 'should call messageVoiceText')
130
+ t.notOk(puppet.messagePayload.called, 'should NOT fall back when supported')
131
+ })
132
+
133
+ test('Voice.text() falls back to messagePayload().text on unsupported error', async t => {
134
+ await ensureStarted()
135
+ puppet.messageVoiceText = sinon.stub().rejects(UNSUPPORTED)
136
+ puppet.messagePayload = sinon.stub().resolves({ text: 'legacy asr' } as PUPPET.payloads.Message)
137
+
138
+ const result = await voice().text()
139
+ t.same(result, { text: 'legacy asr', noSpeech: false }, 'should fall back to legacy payload.text with noSpeech=false')
140
+ t.ok(puppet.messagePayload.calledOnceWith(MESSAGE_ID), 'should call messagePayload on fallback')
141
+ })
142
+
143
+ test('Voice.text() falls back on gRPC UNIMPLEMENTED (code 12)', async t => {
144
+ await ensureStarted()
145
+ const unimplemented = Object.assign(new Error('12 UNIMPLEMENTED'), { code: 12 })
146
+ puppet.messageVoiceText = sinon.stub().rejects(unimplemented)
147
+ puppet.messagePayload = sinon.stub().resolves({ text: 'legacy asr' } as PUPPET.payloads.Message)
148
+
149
+ const result = await voice().text()
150
+ t.same(result, { text: 'legacy asr', noSpeech: false }, 'should fall back when the server returns UNIMPLEMENTED')
151
+ t.ok(puppet.messagePayload.calledOnceWith(MESSAGE_ID), 'should call messagePayload on fallback')
152
+ })
153
+
154
+ test('Voice.text() falls back to messagePayload when messageVoiceText is absent (old puppet)', async t => {
155
+ await ensureStarted()
156
+ puppet.messageVoiceText = undefined // old puppet built without the method at all
157
+ puppet.messagePayload = sinon.stub().resolves({ text: 'legacy asr' } as PUPPET.payloads.Message)
158
+
159
+ const result = await voice().text()
160
+ t.same(result, { text: 'legacy asr', noSpeech: false }, 'should fall back via the typeof guard with noSpeech=false')
161
+ t.ok(puppet.messagePayload.calledOnceWith(MESSAGE_ID), 'should call messagePayload on fallback')
162
+ })
163
+
164
+ test('Voice.text() rethrows a transient error instead of falling back', async t => {
165
+ await ensureStarted()
166
+ puppet.messageVoiceText = sinon.stub().rejects(new Error('ETIMEDOUT'))
167
+ puppet.messagePayload = sinon.stub().resolves({ text: 'legacy asr' } as PUPPET.payloads.Message)
168
+
169
+ await t.rejects(() => voice().text(), /ETIMEDOUT/, 'should rethrow the transient error')
170
+ t.notOk(puppet.messagePayload.called, 'should NOT fall back on a transient error (would drop noSpeech)')
171
+ })
172
+
173
+ test('Voice.text() rethrows a genuine "is not a function" TypeError from a working messageVoiceText', async t => {
174
+ await ensureStarted()
175
+ // regression: a real internal TypeError must NOT be downgraded — that would
176
+ // drop noSpeech and make the bot re-run its own paid ASR
177
+ puppet.messageVoiceText = sinon.stub().rejects(new TypeError('innerHelper.process is not a function'))
178
+ puppet.messagePayload = sinon.stub().resolves({ text: 'legacy asr' } as PUPPET.payloads.Message)
179
+
180
+ await t.rejects(() => voice().text(), /is not a function/, 'should rethrow the internal TypeError')
181
+ t.notOk(puppet.messagePayload.called, 'should NOT mask a real implementation bug')
182
+ })
183
+
184
+ test('teardown: stop the shared wechaty', async t => {
185
+ await wechaty.stop()
186
+ t.pass('stopped')
187
+ })
@@ -21,6 +21,7 @@ import type {
21
21
  FileBoxInterface,
22
22
  } from 'file-box'
23
23
  import type { Constructor } from 'clone-class'
24
+ import type * as PUPPET from '@juzi/wechaty-puppet'
24
25
  import { validationMixin } from '../user-mixins/validation.js'
25
26
  import { log } from '../config.js'
26
27
 
@@ -28,6 +29,36 @@ import {
28
29
  wechatifyMixinBase,
29
30
  } from '../user-mixins/wechatify.js'
30
31
 
32
+ /**
33
+ * gRPC status code for an RPC the server does not implement.
34
+ * @see https://grpc.github.io/grpc/core/md_doc_statuscodes.html
35
+ */
36
+ const GRPC_STATUS_UNIMPLEMENTED = 12
37
+
38
+ /**
39
+ * Whether `e` means the puppet / server does not implement the new voice RPC,
40
+ * i.e. it is safe to fall back to the legacy path.
41
+ *
42
+ * Transient errors (network / timeout / file expired / other gRPC codes) must
43
+ * NOT match here: otherwise the fallback silently masks real failures and — for
44
+ * `text()` — discards the `noSpeech` discriminator, making the bot re-run its
45
+ * own paid ASR on voices the puppet already confirmed as empty.
46
+ *
47
+ * Note: the "method does not exist at all" case (an old puppet built against a
48
+ * wechaty-puppet without the method) is detected up-front via a `typeof` guard
49
+ * at the call site, NOT here — matching the `'is not a function'` text would
50
+ * also swallow a genuine `TypeError` raised inside a working implementation.
51
+ */
52
+ function isUnsupportedError (e: unknown): boolean {
53
+ const err = e as { code?: number | string; message?: string } | undefined
54
+ // old wechaty-puppet-service server without the new RPC → gRPC UNIMPLEMENTED
55
+ if (err?.code === GRPC_STATUS_UNIMPLEMENTED) {
56
+ return true
57
+ }
58
+ // puppet implements the abstract method via throwUnsupportedError()
59
+ return (err?.message || '').includes('Unsupported API')
60
+ }
61
+
31
62
  class VoiceMixin extends wechatifyMixinBase() {
32
63
 
33
64
  static create (id: string): VoiceInterface {
@@ -48,17 +79,28 @@ class VoiceMixin extends wechatifyMixinBase() {
48
79
  * Get the voice file (FileBox) via the puppet's dedicated `messageVoice` RPC.
49
80
  *
50
81
  * Falls back to the generic `messageFile` when the puppet does not implement
51
- * `messageVoice` (old puppet without the new RPC rejection), keeping the
52
- * behaviour compatible with the legacy "voice file via messageFile" path.
82
+ * `messageVoice` either the method is absent (old puppet built without it,
83
+ * caught by the `typeof` guard) or it rejects with an unsupported-API error.
84
+ * Keeps the behaviour compatible with the legacy "voice file via messageFile"
85
+ * path. A genuine runtime error from a working `messageVoice` is rethrown.
53
86
  */
54
87
  async file (): Promise<FileBoxInterface> {
55
88
  log.verbose('Voice', 'file() for id: "%s"', this.id)
89
+ const puppet = this.wechaty.puppet
90
+ // puppet built against an old wechaty-puppet without the method at all
91
+ if (typeof (puppet as { messageVoice?: unknown }).messageVoice !== 'function') {
92
+ log.verbose('Voice', 'file() messageVoice() absent, fallback to messageFile()')
93
+ return puppet.messageFile(this.id)
94
+ }
56
95
  try {
57
- const fileBox = await this.wechaty.puppet.messageVoice(this.id)
96
+ const fileBox = await puppet.messageVoice(this.id)
58
97
  return fileBox
59
98
  } catch (e) {
60
- log.verbose('Voice', 'file() messageVoice() failed, fallback to messageFile(): %s', (e as Error).message)
61
- const fileBox = await this.wechaty.puppet.messageFile(this.id)
99
+ if (!isUnsupportedError(e)) {
100
+ throw e
101
+ }
102
+ log.verbose('Voice', 'file() messageVoice() unsupported, fallback to messageFile(): %s', (e as Error).message)
103
+ const fileBox = await puppet.messageFile(this.id)
62
104
  return fileBox
63
105
  }
64
106
  }
@@ -69,18 +111,31 @@ class VoiceMixin extends wechatifyMixinBase() {
69
111
  * "confirmed no speech" (`noSpeech=true`) from "normal transcription".
70
112
  *
71
113
  * Falls back to reading the legacy `messagePayload().text` when the puppet
72
- * does not implement `messageVoiceText` (old puppet without the new RPC
73
- * rejection). Old puppets put the ASR result into `payload.text`, so this
74
- * keeps the behaviour compatible; `noSpeech` is `false` in the fallback.
114
+ * does not implement `messageVoiceText` either the method is absent (old
115
+ * puppet built without it, caught by the `typeof` guard) or it rejects with
116
+ * an unsupported-API error. Old puppets put the ASR result into
117
+ * `payload.text`, so this keeps the behaviour compatible; `noSpeech` is
118
+ * `false` in the fallback. A genuine runtime error is rethrown (so the bot
119
+ * does not silently drop `noSpeech` and re-run its own paid ASR).
75
120
  */
76
- async text (): Promise<{ text: string; noSpeech: boolean }> {
121
+ async text (): Promise<PUPPET.payloads.VoiceText> {
77
122
  log.verbose('Voice', 'text() for id: "%s"', this.id)
123
+ const puppet = this.wechaty.puppet
124
+ // puppet built against an old wechaty-puppet without the method at all
125
+ if (typeof (puppet as { messageVoiceText?: unknown }).messageVoiceText !== 'function') {
126
+ log.verbose('Voice', 'text() messageVoiceText() absent, fallback to messagePayload().text')
127
+ const payload = await puppet.messagePayload(this.id)
128
+ return { text: payload.text || '', noSpeech: false }
129
+ }
78
130
  try {
79
- const payload = await this.wechaty.puppet.messageVoiceText(this.id)
131
+ const payload = await puppet.messageVoiceText(this.id)
80
132
  return payload
81
133
  } catch (e) {
82
- log.verbose('Voice', 'text() messageVoiceText() failed, fallback to messagePayload().text: %s', (e as Error).message)
83
- const payload = await this.wechaty.puppet.messagePayload(this.id)
134
+ if (!isUnsupportedError(e)) {
135
+ throw e
136
+ }
137
+ log.verbose('Voice', 'text() messageVoiceText() unsupported, fallback to messagePayload().text: %s', (e as Error).message)
138
+ const payload = await puppet.messagePayload(this.id)
84
139
  return { text: payload.text || '', noSpeech: false }
85
140
  }
86
141
  }
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env -S node --no-warnings --loader ts-node/esm
2
+
3
+ import { test } from 'tstest'
4
+ import * as PUPPET from '@juzi/wechaty-puppet'
5
+ import { PuppetMock } from '@juzi/wechaty-puppet-mock'
6
+
7
+ import { WechatyBuilder } from '../wechaty-builder.js'
8
+
9
+ /**
10
+ * Regression: the dirty event listener for `Payload.Call` in `puppet-mixin`
11
+ * used to do `await this.puppet.callPayloadDirty(payloadId)` inside the
12
+ * handler itself.
13
+ *
14
+ * Under wechaty-puppet-service, `callPayloadDirty` fires a gRPC
15
+ * `DirtyPayload` to the server, which then re-emits `dirty` back to the
16
+ * client through the event stream. That re-emit triggers the handler
17
+ * again, forming an infinite client⇄server feedback loop.
18
+ *
19
+ * All other dirty branches (Contact / Room / Message / Wxxd*) only call
20
+ * `find(...).ready(true)`; the Call branch was the outlier.
21
+ *
22
+ * The fix removes the in-handler `callPayloadDirty` call so the branch
23
+ * matches the rest of the template. This test pins that contract:
24
+ * emitting a Call-typed dirty event must NOT cause the handler to call
25
+ * `puppet.callPayloadDirty` again.
26
+ */
27
+ test('dirty(Call) handler must NOT call callPayloadDirty (would form a gRPC loop)', async t => {
28
+ const puppet = new PuppetMock() as any
29
+ const wechaty = WechatyBuilder.build({ puppet })
30
+
31
+ await wechaty.start()
32
+
33
+ let dirtyCallCount = 0
34
+ const originalCallPayloadDirty = puppet.callPayloadDirty.bind(puppet)
35
+ puppet.callPayloadDirty = async (id: string) => {
36
+ dirtyCallCount++
37
+ return originalCallPayloadDirty(id)
38
+ }
39
+
40
+ puppet.emit('dirty', {
41
+ payloadType: PUPPET.types.Payload.Call,
42
+ payloadId: 'call-id-x',
43
+ })
44
+
45
+ // Allow the async handler to run.
46
+ await new Promise(resolve => setTimeout(resolve, 50))
47
+
48
+ t.equal(
49
+ dirtyCallCount,
50
+ 0,
51
+ `dirty(Call) handler must not re-invoke callPayloadDirty; got ${dirtyCallCount} call(s)`,
52
+ )
53
+
54
+ await wechaty.stop()
55
+ })
@@ -753,16 +753,21 @@ const puppetMixin = <MixinBase extends WechatifyUserModuleMixin & GErrorMixin &
753
753
  }
754
754
  case PUPPET.types.Payload.Call: {
755
755
  /**
756
- * Invalidate the puppet-side cache so the next callPayload()
757
- * pull reflects the latest server state, then refresh the
758
- * user-layer Call payload so getters (media / participants /
759
- * endTime) reflect the new view immediately. Whether the
760
- * call is still alive is still decided by the call-event
761
- * handler via __finalizeIfEnded; we do not touch the pool
762
- * here, and we do not emit a synthetic user event — the
763
- * UI is expected to read getters when it needs the value.
756
+ * Refresh the user-layer Call payload so getters (media /
757
+ * participants / endTime) reflect the new view. Whether
758
+ * the call is still alive is still decided by the
759
+ * call-event handler via __finalizeIfEnded; we do not
760
+ * touch the pool here, and we do not emit a synthetic
761
+ * user event the UI is expected to read getters when
762
+ * it needs the value.
763
+ *
764
+ * IMPORTANT: do NOT call puppet.callPayloadDirty here.
765
+ * Under puppet-service that fires a gRPC DirtyPayload
766
+ * which the server bounces back as another `dirty`
767
+ * event, forming an infinite client⇄server feedback
768
+ * loop. Cache invalidation is already handled by the
769
+ * cache-mixin onDirty listener registered alongside.
764
770
  */
765
- await this.puppet.callPayloadDirty(payloadId)
766
771
  const call = this.__callPool.get(payloadId)
767
772
  if (call) {
768
773
  try {