@juzi/wechaty 1.0.155 → 1.0.157

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 (62) 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 +24 -0
  9. package/dist/cjs/src/user-modules/contact.d.ts.map +1 -1
  10. package/dist/cjs/src/user-modules/contact.js +69 -5
  11. package/dist/cjs/src/user-modules/contact.js.map +1 -1
  12. package/dist/cjs/src/user-modules/contact.spec.js +156 -0
  13. package/dist/cjs/src/user-modules/contact.spec.js.map +1 -1
  14. package/dist/cjs/src/user-modules/room.d.ts +23 -0
  15. package/dist/cjs/src/user-modules/room.d.ts.map +1 -1
  16. package/dist/cjs/src/user-modules/room.js +52 -0
  17. package/dist/cjs/src/user-modules/room.js.map +1 -1
  18. package/dist/cjs/src/user-modules/room.spec.js +156 -0
  19. package/dist/cjs/src/user-modules/room.spec.js.map +1 -1
  20. package/dist/cjs/src/wechaty-mixins/puppet-mixin-dirty-call.spec.d.ts +3 -0
  21. package/dist/cjs/src/wechaty-mixins/puppet-mixin-dirty-call.spec.d.ts.map +1 -0
  22. package/dist/cjs/src/wechaty-mixins/puppet-mixin-dirty-call.spec.js +68 -0
  23. package/dist/cjs/src/wechaty-mixins/puppet-mixin-dirty-call.spec.js.map +1 -0
  24. package/dist/cjs/src/wechaty-mixins/puppet-mixin.d.ts.map +1 -1
  25. package/dist/cjs/src/wechaty-mixins/puppet-mixin.js +14 -9
  26. package/dist/cjs/src/wechaty-mixins/puppet-mixin.js.map +1 -1
  27. package/dist/esm/src/package-json.js +4 -4
  28. package/dist/esm/src/user-modules/call.spec.js +5 -1
  29. package/dist/esm/src/user-modules/call.spec.js.map +1 -1
  30. package/dist/esm/src/user-modules/contact-alias-polling.spec.d.ts +3 -0
  31. package/dist/esm/src/user-modules/contact-alias-polling.spec.d.ts.map +1 -0
  32. package/dist/esm/src/user-modules/contact-alias-polling.spec.js +56 -0
  33. package/dist/esm/src/user-modules/contact-alias-polling.spec.js.map +1 -0
  34. package/dist/esm/src/user-modules/contact.d.ts +24 -0
  35. package/dist/esm/src/user-modules/contact.d.ts.map +1 -1
  36. package/dist/esm/src/user-modules/contact.js +69 -5
  37. package/dist/esm/src/user-modules/contact.js.map +1 -1
  38. package/dist/esm/src/user-modules/contact.spec.js +157 -1
  39. package/dist/esm/src/user-modules/contact.spec.js.map +1 -1
  40. package/dist/esm/src/user-modules/room.d.ts +23 -0
  41. package/dist/esm/src/user-modules/room.d.ts.map +1 -1
  42. package/dist/esm/src/user-modules/room.js +52 -0
  43. package/dist/esm/src/user-modules/room.js.map +1 -1
  44. package/dist/esm/src/user-modules/room.spec.js +156 -0
  45. package/dist/esm/src/user-modules/room.spec.js.map +1 -1
  46. package/dist/esm/src/wechaty-mixins/puppet-mixin-dirty-call.spec.d.ts +3 -0
  47. package/dist/esm/src/wechaty-mixins/puppet-mixin-dirty-call.spec.d.ts.map +1 -0
  48. package/dist/esm/src/wechaty-mixins/puppet-mixin-dirty-call.spec.js +43 -0
  49. package/dist/esm/src/wechaty-mixins/puppet-mixin-dirty-call.spec.js.map +1 -0
  50. package/dist/esm/src/wechaty-mixins/puppet-mixin.d.ts.map +1 -1
  51. package/dist/esm/src/wechaty-mixins/puppet-mixin.js +14 -9
  52. package/dist/esm/src/wechaty-mixins/puppet-mixin.js.map +1 -1
  53. package/package.json +3 -3
  54. package/src/package-json.ts +4 -4
  55. package/src/user-modules/call.spec.ts +5 -1
  56. package/src/user-modules/contact-alias-polling.spec.ts +70 -0
  57. package/src/user-modules/contact.spec.ts +212 -0
  58. package/src/user-modules/contact.ts +81 -5
  59. package/src/user-modules/room.spec.ts +210 -0
  60. package/src/user-modules/room.ts +63 -0
  61. package/src/wechaty-mixins/puppet-mixin-dirty-call.spec.ts +55 -0
  62. package/src/wechaty-mixins/puppet-mixin.ts +14 -9
@@ -4,10 +4,10 @@
4
4
  */
5
5
  import type { PackageJsonWechaty } from './config'
6
6
 
7
- export const GIT_COMMIT_HASH: string = '6ef3d89f2a44f5454d45c56ff45765c9eeca8a79'
7
+ export const GIT_COMMIT_HASH: string = 'a69947004cdd32876cf204ef4344e10443d444a3'
8
8
  export const packageJson: PackageJsonWechaty = {
9
9
  "name": "@juzi/wechaty",
10
- "version": "1.0.155",
10
+ "version": "1.0.157",
11
11
  "description": "Wechaty is a RPA SDK for Chatbot Makers.",
12
12
  "type": "module",
13
13
  "exports": {
@@ -116,7 +116,7 @@ export const packageJson: PackageJsonWechaty = {
116
116
  },
117
117
  "homepage": "https://github.com/wechaty/",
118
118
  "dependencies": {
119
- "@juzi/wechaty-puppet-service": "1.0.119",
119
+ "@juzi/wechaty-puppet-service": "1.0.121",
120
120
  "clone-class": "^1.1.1",
121
121
  "cmd-ts": "^0.10.0",
122
122
  "cockatiel": "^2.0.2",
@@ -139,7 +139,7 @@ export const packageJson: PackageJsonWechaty = {
139
139
  "@chatie/eslint-config": "^1.0.4",
140
140
  "@chatie/semver": "^0.4.7",
141
141
  "@chatie/tsconfig": "^4.6.3",
142
- "@juzi/wechaty-puppet": "^1.0.143",
142
+ "@juzi/wechaty-puppet": "^1.0.145",
143
143
  "@juzi/wechaty-puppet-mock": "^1.0.1",
144
144
  "@swc/core": "1.3.44",
145
145
  "@swc/helpers": "^0.3.6",
@@ -922,7 +922,11 @@ test('dirty(Call) refreshes user-layer payload so getters see new value', async
922
922
  await flush()
923
923
 
924
924
  t.equal(incoming!.media(), PUPPET.types.CallMediaType.Video, 'media should reflect refreshed payload after dirty')
925
- t.ok((puppet.callPayloadDirty as any).called, 'puppet.callPayloadDirty should be invoked')
925
+ // Note: the handler intentionally does NOT call puppet.callPayloadDirty;
926
+ // doing so would form a gRPC loop under puppet-service (server bounces the
927
+ // dirty back through the event stream). Cache invalidation is handled by
928
+ // the cache-mixin onDirty listener; the user-visible contract is that
929
+ // .media() above reflects the fresh server value.
926
930
 
927
931
  await wechaty.stop()
928
932
  sandbox.restore()
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env -S node --no-warnings --loader ts-node/esm
2
+
3
+ import { test } from 'tstest'
4
+ import { PuppetMock } from '@juzi/wechaty-puppet-mock'
5
+
6
+ import { WechatyBuilder } from '../wechaty-builder.js'
7
+
8
+ /**
9
+ * Regression: `Contact.alias(newAlias)` invokes
10
+ * 1. puppet.contactAlias(id, newAlias) // gRPC write
11
+ * 2. puppet.contactPayloadDirty(id) // ONCE, outside the loop ← BUG
12
+ * 3. loop 10× { sleep 300ms; puppet.contactPayload(id); check }
13
+ *
14
+ * Under puppet-service the first contactPayload() call after a dirty
15
+ * clears both the LRU and the FlashStore, then refills both with the
16
+ * fresh fetch. LRU TTL is 15 minutes, so iterations 2..10 hit the LRU
17
+ * and return iteration-1's payload forever. If the underlying protocol
18
+ * (puppet-phoenix etc.) was still propagating the alias change at the
19
+ * moment of iteration 1, the 9 follow-up retries become no-ops and
20
+ * `alias()` throws "still got old alias after 10 tries".
21
+ *
22
+ * The fix moves contactPayloadDirty INTO the loop so every retry
23
+ * forces a fresh fetch. This test pins that contract: under a forced
24
+ * stale-read scenario, contactPayloadDirty must be called more than
25
+ * once.
26
+ */
27
+ test('alias() must call contactPayloadDirty on every poll iteration', async t => {
28
+ const puppet = new PuppetMock() as any
29
+ const wechaty = WechatyBuilder.build({ puppet })
30
+
31
+ // Swallow the wechaty-level error the alias() impl re-emits after
32
+ // its 10 poll iterations all fail to confirm the new alias; without
33
+ // this, the unhandled 'error' event tears down the test.
34
+ wechaty.on('error', () => {})
35
+
36
+ const mockMe = puppet.mocker.createContact({ name: 'me' })
37
+ const mockUser = puppet.mocker.createContact({ name: 'Alice' })
38
+
39
+ await wechaty.start()
40
+ await puppet.mocker.login(mockMe)
41
+
42
+ let dirtyCount = 0
43
+ puppet.contactPayloadDirty = async (_: string) => {
44
+ dirtyCount++
45
+ }
46
+
47
+ // Simulate the protocol-layer "alias write accepted" but
48
+ // payload reads keep returning the OLD alias — the exact stuck
49
+ // scenario the polling loop is supposed to wait out.
50
+ puppet.contactAlias = async (_id: string, _newAlias: string) => {}
51
+ puppet.contactPayload = async (id: string) => ({
52
+ id,
53
+ alias: 'OLD',
54
+ name: 'Alice',
55
+ })
56
+
57
+ const contact = await wechaty.Contact.find({ id: mockUser.id })
58
+ t.ok(contact, 'sanity: find returned a contact')
59
+
60
+ // alias() traps internally and emits through wechaty error channel.
61
+ // We do NOT assert on the throw — only on dirty-call cadence.
62
+ await contact!.alias('NEW')
63
+
64
+ t.ok(
65
+ dirtyCount > 1,
66
+ `contactPayloadDirty must be called inside the retry loop, not just once before it; got ${dirtyCount}`,
67
+ )
68
+
69
+ await wechaty.stop()
70
+ })
@@ -20,8 +20,10 @@
20
20
  */
21
21
  import {
22
22
  test,
23
+ sinon,
23
24
  } from 'tstest'
24
25
 
26
+ import type * as PUPPET from '@juzi/wechaty-puppet'
25
27
  import { PuppetMock } from '@juzi/wechaty-puppet-mock'
26
28
  import { WechatyBuilder } from '../wechaty-builder.js'
27
29
 
@@ -88,6 +90,216 @@ test('should throw when instanciate the global class', async t => {
88
90
  }, 'should throw when we instanciate a global class')
89
91
  })
90
92
 
93
+ test('findAllIter() yields every contact returned by contactSearch', async t => {
94
+ const TOTAL = 7
95
+
96
+ const sandbox = sinon.createSandbox()
97
+ const puppet = new PuppetMock() as any
98
+ const wechaty = WechatyBuilder.build({ puppet })
99
+
100
+ const idList = Array.from({ length: TOTAL }, (_, idx) => `c-${idx}`)
101
+ const buildPayload = (id: string) => ({ id, name: `name-${id}` } as PUPPET.payloads.Contact)
102
+
103
+ sandbox.stub(puppet, 'contactSearch').resolves(idList)
104
+ sandbox.stub(puppet, 'batchContactPayload').callsFake(async (...args: any[]) => {
105
+ const ids = args[0] as string[]
106
+ return new Map(ids.map(id => [ id, buildPayload(id) ]))
107
+ })
108
+
109
+ await wechaty.start()
110
+ const bot = puppet.mocker.createContact({ name: 'bot' })
111
+ await puppet.mocker.login(bot)
112
+
113
+ const seen: string[] = []
114
+ for await (const chunk of wechaty.Contact.findAllIter()) {
115
+ for (const contact of chunk) {
116
+ seen.push(contact.id)
117
+ }
118
+ }
119
+
120
+ t.equal(seen.length, TOTAL, `should yield ${TOTAL} contacts`)
121
+ t.same(seen, idList, 'should yield contacts in search order')
122
+
123
+ await wechaty.stop()
124
+ })
125
+
126
+ test('findAllIter() chunks 101 contacts into [100, 1] at default batch=100', async t => {
127
+ const TOTAL = 101
128
+
129
+ const sandbox = sinon.createSandbox()
130
+ const puppet = new PuppetMock() as any
131
+ const wechaty = WechatyBuilder.build({ puppet })
132
+
133
+ const idList = Array.from({ length: TOTAL }, (_, idx) => `c-${idx}`)
134
+ sandbox.stub(puppet, 'contactSearch').resolves(idList)
135
+ sandbox.stub(puppet, 'batchContactPayload').callsFake(async (...args: any[]) => {
136
+ const ids = args[0] as string[]
137
+ return new Map(ids.map(id => [ id, { id, name: id } as PUPPET.payloads.Contact ]))
138
+ })
139
+
140
+ await wechaty.start()
141
+ const bot = puppet.mocker.createContact({ name: 'bot' })
142
+ await puppet.mocker.login(bot)
143
+
144
+ const sizes: number[] = []
145
+ for await (const chunk of wechaty.Contact.findAllIter()) {
146
+ sizes.push(chunk.length)
147
+ }
148
+
149
+ t.same(sizes, [ 100, 1 ], 'should split into two batches: 100 and 1')
150
+
151
+ await wechaty.stop()
152
+ })
153
+
154
+ test('findAllIter() honors early break without throwing', async t => {
155
+ const TOTAL = 250
156
+
157
+ const sandbox = sinon.createSandbox()
158
+ const puppet = new PuppetMock() as any
159
+ const wechaty = WechatyBuilder.build({ puppet })
160
+
161
+ const idList = Array.from({ length: TOTAL }, (_, idx) => `c-${idx}`)
162
+ sandbox.stub(puppet, 'contactSearch').resolves(idList)
163
+ const batchSpy = sandbox.stub(puppet, 'batchContactPayload').callsFake(async (...args: any[]) => {
164
+ const ids = args[0] as string[]
165
+ return new Map(ids.map(id => [ id, { id, name: id } as PUPPET.payloads.Contact ]))
166
+ })
167
+
168
+ await wechaty.start()
169
+ const bot = puppet.mocker.createContact({ name: 'bot' })
170
+ await puppet.mocker.login(bot)
171
+
172
+ let received = 0
173
+ await t.resolves((async () => {
174
+ for await (const chunk of wechaty.Contact.findAllIter()) {
175
+ received += chunk.length
176
+ if (received >= 100) {
177
+ break
178
+ }
179
+ }
180
+ })(), 'breaking the loop should not throw')
181
+
182
+ t.equal(received, 100, 'should stop after the first batch')
183
+ t.equal(batchSpy.callCount, 1, 'should not invoke batchContactPayload again after break')
184
+
185
+ await wechaty.stop()
186
+ })
187
+
188
+ test('findAllIter() invokes batchContactPayload ceil(N/batch) times, not N times', async t => {
189
+ const TOTAL = 100
190
+ const BATCH = 25
191
+
192
+ const sandbox = sinon.createSandbox()
193
+ const puppet = new PuppetMock() as any
194
+ const wechaty = WechatyBuilder.build({ puppet })
195
+
196
+ const idList = Array.from({ length: TOTAL }, (_, idx) => `c-${idx}`)
197
+ sandbox.stub(puppet, 'contactSearch').resolves(idList)
198
+
199
+ const batchSpy = sandbox.stub(puppet, 'batchContactPayload').callsFake(async (...args: any[]) => {
200
+ const ids = args[0] as string[]
201
+ return new Map(ids.map(id => [ id, { id, name: id } as PUPPET.payloads.Contact ]))
202
+ })
203
+ const rawSpy = sandbox.spy(puppet, 'contactRawPayload')
204
+
205
+ await wechaty.start()
206
+ const bot = puppet.mocker.createContact({ name: 'bot' })
207
+ await puppet.mocker.login(bot)
208
+
209
+ const rawCountBefore = rawSpy.callCount
210
+
211
+ let received = 0
212
+ for await (const chunk of wechaty.Contact.findAllIter(undefined, { batch: BATCH })) {
213
+ received += chunk.length
214
+ }
215
+
216
+ t.equal(received, TOTAL, 'should iterate all contacts')
217
+ t.equal(batchSpy.callCount, Math.ceil(TOTAL / BATCH), `should invoke batchContactPayload ${Math.ceil(TOTAL / BATCH)} times`)
218
+ t.equal(rawSpy.callCount, rawCountBefore, 'should never fall back to per-id contactRawPayload during iteration')
219
+
220
+ await wechaty.stop()
221
+ })
222
+
223
+ test('findAllIter() with empty contactSearch yields nothing and does not throw', async t => {
224
+ const sandbox = sinon.createSandbox()
225
+ const puppet = new PuppetMock() as any
226
+ const wechaty = WechatyBuilder.build({ puppet })
227
+
228
+ sandbox.stub(puppet, 'contactSearch').resolves([])
229
+ const batchSpy = sandbox.stub(puppet, 'batchContactPayload').resolves(new Map())
230
+
231
+ await wechaty.start()
232
+ const bot = puppet.mocker.createContact({ name: 'bot' })
233
+ await puppet.mocker.login(bot)
234
+
235
+ let chunkCount = 0
236
+ await t.resolves((async () => {
237
+ for await (const _chunk of wechaty.Contact.findAllIter()) {
238
+ chunkCount++
239
+ }
240
+ })(), 'empty iteration should not throw')
241
+
242
+ t.equal(chunkCount, 0, 'should yield zero chunks')
243
+ t.equal(batchSpy.callCount, 0, 'should not call batchContactPayload when no ids')
244
+
245
+ await wechaty.stop()
246
+ })
247
+
248
+ test('findAllIter() throws when batch <= 0', async t => {
249
+ const sandbox = sinon.createSandbox()
250
+ const puppet = new PuppetMock() as any
251
+ const wechaty = WechatyBuilder.build({ puppet })
252
+
253
+ sandbox.stub(puppet, 'contactSearch').resolves([])
254
+ sandbox.stub(puppet, 'batchContactPayload').resolves(new Map())
255
+
256
+ await wechaty.start()
257
+ const bot = puppet.mocker.createContact({ name: 'bot' })
258
+ await puppet.mocker.login(bot)
259
+
260
+ for (const badBatch of [ 0, -1 ]) {
261
+ await t.rejects(
262
+ (async () => {
263
+ for await (const _chunk of wechaty.Contact.findAllIter(undefined, { batch: badBatch })) {
264
+ // should never reach here
265
+ }
266
+ })(),
267
+ /batch must be positive/,
268
+ `should throw when batch=${badBatch}`,
269
+ )
270
+ }
271
+
272
+ await wechaty.stop()
273
+ })
274
+
275
+ test('findAllIter() skips ids missing from payloadMap', async t => {
276
+ const sandbox = sinon.createSandbox()
277
+ const puppet = new PuppetMock() as any
278
+ const wechaty = WechatyBuilder.build({ puppet })
279
+
280
+ sandbox.stub(puppet, 'contactSearch').resolves([ 'c1', 'c2', 'c3' ])
281
+ sandbox.stub(puppet, 'batchContactPayload').resolves(
282
+ new Map([ [ 'c1', { id: 'c1', name: 'name-c1' } as PUPPET.payloads.Contact ] ]),
283
+ )
284
+
285
+ await wechaty.start()
286
+ const bot = puppet.mocker.createContact({ name: 'bot' })
287
+ await puppet.mocker.login(bot)
288
+
289
+ const seen: string[] = []
290
+ await t.resolves((async () => {
291
+ for await (const chunk of wechaty.Contact.findAllIter()) {
292
+ for (const contact of chunk) {
293
+ seen.push(contact.id)
294
+ }
295
+ }
296
+ })(), 'missing ids should not throw or stall iteration')
297
+
298
+ t.same(seen, [ 'c1' ], 'should yield only contacts whose payload was returned')
299
+
300
+ await wechaty.stop()
301
+ })
302
+
91
303
  test('ProtectedProperties', async t => {
92
304
  type NotExistInWechaty = Exclude<ContactProtectedProperty, keyof ContactImpl>
93
305
  type NotExistTest = NotExistInWechaty extends never ? true : false
@@ -216,6 +216,73 @@ class ContactMixin extends MixinBase implements SayableSayer {
216
216
  return contactList
217
217
  }
218
218
 
219
+ /**
220
+ * Async iterator variant of {@link findAll} that yields contacts in batches.
221
+ *
222
+ * Each batch is materialised with a single `puppet.batchContactPayload` RPC,
223
+ * so iterating N contacts costs roughly `ceil(N / batch)` round trips instead
224
+ * of N. Uses `puppet.batchContactPayload` directly (required on the puppet
225
+ * abstract), without holding the whole list in memory or blocking the caller
226
+ * until every contact has been hydrated. Callers may `break` between batches
227
+ * to short-circuit.
228
+ *
229
+ * @static
230
+ * @param {string | PUPPET.filters.Contact} [query]
231
+ * @param {{ batch?: number }} [opts] batch size, default 100
232
+ * @returns {AsyncIterable<ContactInterface[]>}
233
+ * @example
234
+ * for await (const chunk of bot.Contact.findAllIter()) {
235
+ * for (const contact of chunk) {
236
+ * console.log(contact.name())
237
+ * }
238
+ * }
239
+ */
240
+ static async * findAllIter (
241
+ query? : string | PUPPET.filters.Contact,
242
+ opts? : { batch?: number },
243
+ ): AsyncIterable<ContactInterface[]> {
244
+ const batch = opts?.batch ?? 100
245
+ log.verbose('Contact', 'findAllIter(%s, batch=%d)', JSON.stringify(query, stringifyFilter) || '', batch)
246
+
247
+ if (batch <= 0) {
248
+ throw new Error(`Contact.findAllIter() batch must be positive, got ${batch}`)
249
+ }
250
+
251
+ const contactIdList: string[] = await this.wechaty.puppet.contactSearch(query)
252
+
253
+ if (!this.wechaty.isLoggedIn) {
254
+ throw new Error('Contact.findAllIter() requires logged in')
255
+ }
256
+
257
+ for (let i = 0; i < contactIdList.length; i += batch) {
258
+ const idChunk = contactIdList.slice(i, i + batch)
259
+
260
+ const payloadMap = await this.wechaty.puppet.batchContactPayload(idChunk)
261
+
262
+ const contactChunk: ContactInterface[] = []
263
+ for (const id of idChunk) {
264
+ const payload = payloadMap.get(id)
265
+ if (!payload) {
266
+ log.silly('Contact', 'findAllIter() payload missing for id=%s, skip', id)
267
+ continue
268
+ }
269
+
270
+ const contact: ContactImpl = this.wechaty.puppet.currentUserId === id
271
+ ? (this.wechaty.ContactSelf as any as typeof ContactSelfImpl).load(id)
272
+ : (this.wechaty.Contact as any as typeof ContactImpl).load(id)
273
+
274
+ contact.payload = payload
275
+ contactChunk.push(contact)
276
+ }
277
+
278
+ if (contactChunk.length > 0) {
279
+ yield contactChunk
280
+ } else {
281
+ log.warn('Contact', 'findAllIter() batch all missing from payloadMap, idChunk=%j', idChunk)
282
+ }
283
+ }
284
+ }
285
+
219
286
  static async batchLoadContacts (contactIdList: string[]) {
220
287
  if (typeof this.wechaty.puppet.batchContactPayload === 'function') {
221
288
  const contactList: ContactInterface[] = contactIdList.map(id => {
@@ -496,22 +563,31 @@ class ContactMixin extends MixinBase implements SayableSayer {
496
563
 
497
564
  try {
498
565
  await this.wechaty.puppet.contactAlias(this.id, newAlias)
499
- await this.wechaty.puppet.contactPayloadDirty(this.id)
500
566
 
501
567
  /**
502
568
  * In normal puppet, the dirty event handler, onDirty, is a sync function, so the contactPayload will get the new payload
503
569
  * However for wechaty-puppet-service, it uses flashstore to cache payloads, and deleting a cache is an async function
504
- * So there is a chance contactPayload will still get old contact
570
+ * So there is a chance contactPayload will still get old contact.
571
+ *
572
+ * The puppet LRU TTL (15min) is far longer than the 300ms retry
573
+ * tick, so a dirty fired ONCE before the loop only invalidates
574
+ * the cache for iteration 1 — the iteration-1 fetch immediately
575
+ * refills both LRU and FlashStore, and iterations 2..10 hit the
576
+ * LRU and return iteration-1's payload forever. Dirty must fire
577
+ * on every retry so every read goes to source.
505
578
  */
506
579
  let maxCheck = 10
507
580
  let changed = false
508
581
  while (maxCheck-- > 0 && !changed) {
509
- await new Promise(resolve => {
510
- setTimeout(resolve, 300)
511
- })
582
+ await this.wechaty.puppet.contactPayloadDirty(this.id)
512
583
  this.payload = await this.wechaty.puppet.contactPayload(this.id)
513
584
  const payloadAlias = this.payload.alias || ''
514
585
  changed = newAlias === payloadAlias
586
+ if (!changed) {
587
+ await new Promise(resolve => {
588
+ setTimeout(resolve, 300)
589
+ })
590
+ }
515
591
  }
516
592
  if (!changed) {
517
593
  throw new Error('failed to modify clias, still got old alias after 10 tries')
@@ -232,6 +232,216 @@ test('room.say() smoke testing', async () => {
232
232
  await wechaty.stop()
233
233
  })
234
234
 
235
+ test('Room.findAllIter() yields every room returned by roomSearch', async t => {
236
+ const TOTAL = 6
237
+
238
+ const sandbox = sinon.createSandbox()
239
+ const puppet = new PuppetMock() as any
240
+ const wechaty = WechatyBuilder.build({ puppet })
241
+
242
+ const idList = Array.from({ length: TOTAL }, (_, idx) => `r-${idx}`)
243
+ const buildPayload = (id: string) => ({ id, topic: `topic-${id}`, memberIdList: [], adminIdList: [] } as PUPPET.payloads.Room)
244
+
245
+ sandbox.stub(puppet, 'roomSearch').resolves(idList)
246
+ sandbox.stub(puppet, 'batchRoomPayload').callsFake(async (...args: any[]) => {
247
+ const ids = args[0] as string[]
248
+ return new Map(ids.map(id => [ id, buildPayload(id) ]))
249
+ })
250
+
251
+ await wechaty.start()
252
+ const bot = puppet.mocker.createContact({ name: 'bot' })
253
+ await puppet.mocker.login(bot)
254
+
255
+ const seen: string[] = []
256
+ for await (const chunk of wechaty.Room.findAllIter()) {
257
+ for (const room of chunk) {
258
+ seen.push(room.id)
259
+ }
260
+ }
261
+
262
+ t.equal(seen.length, TOTAL, `should yield ${TOTAL} rooms`)
263
+ t.same(seen, idList, 'should yield rooms in search order')
264
+
265
+ await wechaty.stop()
266
+ })
267
+
268
+ test('Room.findAllIter() chunks 101 rooms into [100, 1] at default batch=100', async t => {
269
+ const TOTAL = 101
270
+
271
+ const sandbox = sinon.createSandbox()
272
+ const puppet = new PuppetMock() as any
273
+ const wechaty = WechatyBuilder.build({ puppet })
274
+
275
+ const idList = Array.from({ length: TOTAL }, (_, idx) => `r-${idx}`)
276
+ sandbox.stub(puppet, 'roomSearch').resolves(idList)
277
+ sandbox.stub(puppet, 'batchRoomPayload').callsFake(async (...args: any[]) => {
278
+ const ids = args[0] as string[]
279
+ return new Map(ids.map(id => [ id, { id, topic: id, memberIdList: [], adminIdList: [] } as PUPPET.payloads.Room ]))
280
+ })
281
+
282
+ await wechaty.start()
283
+ const bot = puppet.mocker.createContact({ name: 'bot' })
284
+ await puppet.mocker.login(bot)
285
+
286
+ const sizes: number[] = []
287
+ for await (const chunk of wechaty.Room.findAllIter()) {
288
+ sizes.push(chunk.length)
289
+ }
290
+
291
+ t.same(sizes, [ 100, 1 ], 'should split into two batches: 100 and 1')
292
+
293
+ await wechaty.stop()
294
+ })
295
+
296
+ test('Room.findAllIter() honors early break without throwing', async t => {
297
+ const TOTAL = 250
298
+
299
+ const sandbox = sinon.createSandbox()
300
+ const puppet = new PuppetMock() as any
301
+ const wechaty = WechatyBuilder.build({ puppet })
302
+
303
+ const idList = Array.from({ length: TOTAL }, (_, idx) => `r-${idx}`)
304
+ sandbox.stub(puppet, 'roomSearch').resolves(idList)
305
+ const batchSpy = sandbox.stub(puppet, 'batchRoomPayload').callsFake(async (...args: any[]) => {
306
+ const ids = args[0] as string[]
307
+ return new Map(ids.map(id => [ id, { id, topic: id, memberIdList: [], adminIdList: [] } as PUPPET.payloads.Room ]))
308
+ })
309
+
310
+ await wechaty.start()
311
+ const bot = puppet.mocker.createContact({ name: 'bot' })
312
+ await puppet.mocker.login(bot)
313
+
314
+ let received = 0
315
+ await t.resolves((async () => {
316
+ for await (const chunk of wechaty.Room.findAllIter()) {
317
+ received += chunk.length
318
+ if (received >= 100) {
319
+ break
320
+ }
321
+ }
322
+ })(), 'breaking the loop should not throw')
323
+
324
+ t.equal(received, 100, 'should stop after the first batch')
325
+ t.equal(batchSpy.callCount, 1, 'should not invoke batchRoomPayload again after break')
326
+
327
+ await wechaty.stop()
328
+ })
329
+
330
+ test('Room.findAllIter() invokes batchRoomPayload ceil(N/batch) times, not N times', async t => {
331
+ const TOTAL = 100
332
+ const BATCH = 25
333
+
334
+ const sandbox = sinon.createSandbox()
335
+ const puppet = new PuppetMock() as any
336
+ const wechaty = WechatyBuilder.build({ puppet })
337
+
338
+ const idList = Array.from({ length: TOTAL }, (_, idx) => `r-${idx}`)
339
+ sandbox.stub(puppet, 'roomSearch').resolves(idList)
340
+
341
+ const batchSpy = sandbox.stub(puppet, 'batchRoomPayload').callsFake(async (...args: any[]) => {
342
+ const ids = args[0] as string[]
343
+ return new Map(ids.map(id => [ id, { id, topic: id, memberIdList: [], adminIdList: [] } as PUPPET.payloads.Room ]))
344
+ })
345
+ const rawSpy = sandbox.spy(puppet, 'roomRawPayload')
346
+
347
+ await wechaty.start()
348
+ const bot = puppet.mocker.createContact({ name: 'bot' })
349
+ await puppet.mocker.login(bot)
350
+
351
+ const rawCountBefore = rawSpy.callCount
352
+
353
+ let received = 0
354
+ for await (const chunk of wechaty.Room.findAllIter(undefined, { batch: BATCH })) {
355
+ received += chunk.length
356
+ }
357
+
358
+ t.equal(received, TOTAL, 'should iterate all rooms')
359
+ t.equal(batchSpy.callCount, Math.ceil(TOTAL / BATCH), `should invoke batchRoomPayload ${Math.ceil(TOTAL / BATCH)} times`)
360
+ t.equal(rawSpy.callCount, rawCountBefore, 'should never fall back to per-id roomRawPayload during iteration')
361
+
362
+ await wechaty.stop()
363
+ })
364
+
365
+ test('Room.findAllIter() with empty roomSearch yields nothing and does not throw', async t => {
366
+ const sandbox = sinon.createSandbox()
367
+ const puppet = new PuppetMock() as any
368
+ const wechaty = WechatyBuilder.build({ puppet })
369
+
370
+ sandbox.stub(puppet, 'roomSearch').resolves([])
371
+ const batchSpy = sandbox.stub(puppet, 'batchRoomPayload').resolves(new Map())
372
+
373
+ await wechaty.start()
374
+ const bot = puppet.mocker.createContact({ name: 'bot' })
375
+ await puppet.mocker.login(bot)
376
+
377
+ let chunkCount = 0
378
+ await t.resolves((async () => {
379
+ for await (const _chunk of wechaty.Room.findAllIter()) {
380
+ chunkCount++
381
+ }
382
+ })(), 'empty iteration should not throw')
383
+
384
+ t.equal(chunkCount, 0, 'should yield zero chunks')
385
+ t.equal(batchSpy.callCount, 0, 'should not call batchRoomPayload when no ids')
386
+
387
+ await wechaty.stop()
388
+ })
389
+
390
+ test('Room.findAllIter() throws when batch <= 0', async t => {
391
+ const sandbox = sinon.createSandbox()
392
+ const puppet = new PuppetMock() as any
393
+ const wechaty = WechatyBuilder.build({ puppet })
394
+
395
+ sandbox.stub(puppet, 'roomSearch').resolves([])
396
+ sandbox.stub(puppet, 'batchRoomPayload').resolves(new Map())
397
+
398
+ await wechaty.start()
399
+ const bot = puppet.mocker.createContact({ name: 'bot' })
400
+ await puppet.mocker.login(bot)
401
+
402
+ for (const badBatch of [ 0, -1 ]) {
403
+ await t.rejects(
404
+ (async () => {
405
+ for await (const _chunk of wechaty.Room.findAllIter(undefined, { batch: badBatch })) {
406
+ // should never reach here
407
+ }
408
+ })(),
409
+ /batch must be positive/,
410
+ `should throw when batch=${badBatch}`,
411
+ )
412
+ }
413
+
414
+ await wechaty.stop()
415
+ })
416
+
417
+ test('Room.findAllIter() skips ids missing from payloadMap', async t => {
418
+ const sandbox = sinon.createSandbox()
419
+ const puppet = new PuppetMock() as any
420
+ const wechaty = WechatyBuilder.build({ puppet })
421
+
422
+ sandbox.stub(puppet, 'roomSearch').resolves([ 'r1', 'r2', 'r3' ])
423
+ sandbox.stub(puppet, 'batchRoomPayload').resolves(
424
+ new Map([ [ 'r1', { id: 'r1', topic: 'topic-r1', memberIdList: [], adminIdList: [] } as PUPPET.payloads.Room ] ]),
425
+ )
426
+
427
+ await wechaty.start()
428
+ const bot = puppet.mocker.createContact({ name: 'bot' })
429
+ await puppet.mocker.login(bot)
430
+
431
+ const seen: string[] = []
432
+ await t.resolves((async () => {
433
+ for await (const chunk of wechaty.Room.findAllIter()) {
434
+ for (const room of chunk) {
435
+ seen.push(room.id)
436
+ }
437
+ }
438
+ })(), 'missing ids should not throw or stall iteration')
439
+
440
+ t.same(seen, [ 'r1' ], 'should yield only rooms whose payload was returned')
441
+
442
+ await wechaty.stop()
443
+ })
444
+
235
445
  test('ProtectedProperties', async t => {
236
446
  type NotExistInWechaty = Exclude<RoomProtectedProperty, keyof RoomImpl>
237
447
  type NotExistTest = NotExistInWechaty extends never ? true : false