@juzi/wechaty-puppet-service 1.0.130 → 1.0.131

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 (79) hide show
  1. package/dist/cjs/src/auth/call-cred.d.ts +2 -2
  2. package/dist/cjs/src/auth/call-cred.d.ts.map +1 -1
  3. package/dist/cjs/src/auth/call-cred.js.map +1 -1
  4. package/dist/cjs/src/client/puppet-service.d.ts +25 -2
  5. package/dist/cjs/src/client/puppet-service.d.ts.map +1 -1
  6. package/dist/cjs/src/client/puppet-service.js +389 -124
  7. package/dist/cjs/src/client/puppet-service.js.map +1 -1
  8. package/dist/cjs/src/client/room-cache-coordinator.d.ts +14 -0
  9. package/dist/cjs/src/client/room-cache-coordinator.d.ts.map +1 -0
  10. package/dist/cjs/src/client/room-cache-coordinator.js +53 -0
  11. package/dist/cjs/src/client/room-cache-coordinator.js.map +1 -0
  12. package/dist/cjs/src/client/room-member-cache.spec.d.ts +2 -0
  13. package/dist/cjs/src/client/room-member-cache.spec.d.ts.map +1 -0
  14. package/dist/cjs/src/client/room-member-cache.spec.js +417 -0
  15. package/dist/cjs/src/client/room-member-cache.spec.js.map +1 -0
  16. package/dist/cjs/src/client/room-member-load.spec.d.ts +2 -0
  17. package/dist/cjs/src/client/room-member-load.spec.d.ts.map +1 -0
  18. package/dist/cjs/src/client/room-member-load.spec.js +324 -0
  19. package/dist/cjs/src/client/room-member-load.spec.js.map +1 -0
  20. package/dist/cjs/src/package-json.js +4 -4
  21. package/dist/cjs/src/server/event-stream-manager.d.ts.map +1 -1
  22. package/dist/cjs/src/server/event-stream-manager.js +16 -0
  23. package/dist/cjs/src/server/event-stream-manager.js.map +1 -1
  24. package/dist/cjs/src/server/puppet-implementation.d.ts.map +1 -1
  25. package/dist/cjs/src/server/puppet-implementation.js +91 -28
  26. package/dist/cjs/src/server/puppet-implementation.js.map +1 -1
  27. package/dist/cjs/src/utils/pb-payload-helper.d.ts +10 -0
  28. package/dist/cjs/src/utils/pb-payload-helper.d.ts.map +1 -1
  29. package/dist/cjs/src/utils/pb-payload-helper.js +180 -1
  30. package/dist/cjs/src/utils/pb-payload-helper.js.map +1 -1
  31. package/dist/cjs/src/utils/room-member-load.spec.d.ts +2 -0
  32. package/dist/cjs/src/utils/room-member-load.spec.d.ts.map +1 -0
  33. package/dist/cjs/src/utils/room-member-load.spec.js +83 -0
  34. package/dist/cjs/src/utils/room-member-load.spec.js.map +1 -0
  35. package/dist/esm/src/auth/call-cred.d.ts +2 -2
  36. package/dist/esm/src/auth/call-cred.d.ts.map +1 -1
  37. package/dist/esm/src/auth/call-cred.js.map +1 -1
  38. package/dist/esm/src/client/puppet-service.d.ts +25 -2
  39. package/dist/esm/src/client/puppet-service.d.ts.map +1 -1
  40. package/dist/esm/src/client/puppet-service.js +390 -125
  41. package/dist/esm/src/client/puppet-service.js.map +1 -1
  42. package/dist/esm/src/client/room-cache-coordinator.d.ts +14 -0
  43. package/dist/esm/src/client/room-cache-coordinator.d.ts.map +1 -0
  44. package/dist/esm/src/client/room-cache-coordinator.js +49 -0
  45. package/dist/esm/src/client/room-cache-coordinator.js.map +1 -0
  46. package/dist/esm/src/client/room-member-cache.spec.d.ts +2 -0
  47. package/dist/esm/src/client/room-member-cache.spec.d.ts.map +1 -0
  48. package/dist/esm/src/client/room-member-cache.spec.js +415 -0
  49. package/dist/esm/src/client/room-member-cache.spec.js.map +1 -0
  50. package/dist/esm/src/client/room-member-load.spec.d.ts +2 -0
  51. package/dist/esm/src/client/room-member-load.spec.d.ts.map +1 -0
  52. package/dist/esm/src/client/room-member-load.spec.js +296 -0
  53. package/dist/esm/src/client/room-member-load.spec.js.map +1 -0
  54. package/dist/esm/src/package-json.js +4 -4
  55. package/dist/esm/src/server/event-stream-manager.d.ts.map +1 -1
  56. package/dist/esm/src/server/event-stream-manager.js +16 -0
  57. package/dist/esm/src/server/event-stream-manager.js.map +1 -1
  58. package/dist/esm/src/server/puppet-implementation.d.ts.map +1 -1
  59. package/dist/esm/src/server/puppet-implementation.js +92 -29
  60. package/dist/esm/src/server/puppet-implementation.js.map +1 -1
  61. package/dist/esm/src/utils/pb-payload-helper.d.ts +10 -0
  62. package/dist/esm/src/utils/pb-payload-helper.d.ts.map +1 -1
  63. package/dist/esm/src/utils/pb-payload-helper.js +169 -0
  64. package/dist/esm/src/utils/pb-payload-helper.js.map +1 -1
  65. package/dist/esm/src/utils/room-member-load.spec.d.ts +2 -0
  66. package/dist/esm/src/utils/room-member-load.spec.d.ts.map +1 -0
  67. package/dist/esm/src/utils/room-member-load.spec.js +58 -0
  68. package/dist/esm/src/utils/room-member-load.spec.js.map +1 -0
  69. package/package.json +4 -4
  70. package/src/auth/call-cred.ts +1 -1
  71. package/src/client/puppet-service.ts +324 -138
  72. package/src/client/room-cache-coordinator.ts +50 -0
  73. package/src/client/room-member-cache.spec.ts +448 -0
  74. package/src/client/room-member-load.spec.ts +319 -0
  75. package/src/package-json.ts +4 -4
  76. package/src/server/event-stream-manager.ts +15 -0
  77. package/src/server/puppet-implementation.ts +94 -29
  78. package/src/utils/pb-payload-helper.ts +150 -0
  79. package/src/utils/room-member-load.spec.ts +67 -0
@@ -0,0 +1,319 @@
1
+ import util from 'util'
2
+ import { sinon, test } from 'tstest'
3
+ import { FileBox } from 'file-box'
4
+ import * as PUPPET from '@juzi/wechaty-puppet'
5
+ import { PuppetMock } from '@juzi/wechaty-puppet-mock'
6
+ import { grpc, puppet as grpcPuppet } from '@juzi/wechaty-grpc'
7
+
8
+ import { PuppetService } from './puppet-service.js'
9
+ import { puppetImplementation } from '../server/puppet-implementation.js'
10
+ import { EventStreamManager } from '../server/event-stream-manager.js'
11
+ import { EventTypeRev } from '../event-type-rev.js'
12
+
13
+ interface RpcFixture {
14
+ service: PuppetService
15
+ adapter: PUPPET.impls.PuppetInterface
16
+ client: grpcPuppet.PuppetClient
17
+ close: () => Promise<void>
18
+ }
19
+
20
+ async function createRpcFixture (legacyAdapter?: PUPPET.impls.PuppetInterface): Promise<RpcFixture> {
21
+ // The published mock resolves its own older Puppet dependency.
22
+ const adapter = legacyAdapter ?? Object.assign(new PuppetMock(), {
23
+ roomMemberSync: PUPPET.Puppet.prototype.roomMemberSync,
24
+ roomMemberLoadState: PUPPET.Puppet.prototype.roomMemberLoadState,
25
+ roomMemberSnapshot: PUPPET.Puppet.prototype.roomMemberSnapshot,
26
+ }) as unknown as PUPPET.impls.PuppetInterface
27
+ const server = new grpc.Server()
28
+ server.addService(grpcPuppet.PuppetService, puppetImplementation(adapter, FileBox))
29
+ const port = await util.promisify(server.bindAsync.bind(server))(
30
+ '127.0.0.1:0', grpc.ServerCredentials.createInsecure(),
31
+ )
32
+ const client = new grpcPuppet.PuppetClient(`127.0.0.1:${port}`, grpc.credentials.createInsecure())
33
+ const service = new PuppetService({ token: `load-test-${port}` })
34
+ Object.defineProperty(service, 'grpcManager', { value: { client } })
35
+ return {
36
+ adapter,
37
+ service,
38
+ client,
39
+ close: async (): Promise<void> => {
40
+ client.close()
41
+ await util.promisify(server.tryShutdown.bind(server))()
42
+ },
43
+ }
44
+ }
45
+
46
+ const state: PUPPET.payloads.RoomMemberLoadState = {
47
+ roomId: 'room', taskId: 'task', status: 3, revision: '9007199254740993', loadedAt: 123456789,
48
+ }
49
+
50
+ test('real gRPC sync, state, and snapshot roundtrip without dirty fallback', async t => {
51
+ const { adapter, service, close } = await createRpcFixture()
52
+ t.teardown(close)
53
+ const accepted: PUPPET.payloads.RoomMemberLoadState = {
54
+ roomId: 'room', taskId: 'task', status: PUPPET.types.RoomMemberLoadStatus.Pending, revision: '0',
55
+ }
56
+ const sync = sinon.stub(adapter, 'roomMemberSync').resolves(accepted)
57
+ const read = sinon.stub(adapter, 'roomMemberLoadState').resolves(state)
58
+ const snapshot = sinon.stub(adapter, 'roomMemberSnapshot').resolves({
59
+ roomId: 'room',
60
+ revision: state.revision,
61
+ loadedAt: state.loadedAt,
62
+ members: [ {
63
+ id: 'member',
64
+ name: 'name',
65
+ avatar: 'avatar',
66
+ roomAlias: 'alias',
67
+ inviterId: 'inviter',
68
+ additionalInfo: 'info',
69
+ joinTime: 12,
70
+ joinScene: 0,
71
+ } ],
72
+ nextPageToken: 'next',
73
+ })
74
+ const dirty = sinon.spy(adapter, 'dirtyPayload')
75
+ t.same(await service.roomMemberSync('room', { force: true }), accepted, 'returns accepted Pending state without waiting for Loaded')
76
+ t.same(sync.args[0], [ 'room', { force: true } ])
77
+ await service.roomMemberSync('room')
78
+ t.same(sync.args[1], [ 'room', undefined ], 'does not inject default force')
79
+ t.same(await service.roomMemberLoadState('room'), state)
80
+ t.equal(read.callCount, 1)
81
+ const page = await service.roomMemberSnapshot('room', state.revision, { pageToken: 'cursor', pageSize: 2 })
82
+ t.same(snapshot.args[0], [ 'room', state.revision, { pageToken: 'cursor', pageSize: 2 } ])
83
+ t.equal(page.nextPageToken, 'next')
84
+ t.equal(page.members[0]?.roomAlias, 'alias')
85
+ t.equal(page.loadedAt, state.loadedAt)
86
+ t.equal(dirty.callCount, 0)
87
+ await service.roomMemberSnapshot('room', state.revision)
88
+ t.same(snapshot.args[1], [ 'room', state.revision, { pageToken: '', pageSize: 0 } ])
89
+ await t.rejects(service.roomMemberSnapshot('room', '01'), /revision/)
90
+ await t.rejects(service.roomMemberSnapshot('room', '1', { pageSize: 501 }), /pageSize/)
91
+ })
92
+
93
+ test('snapshot conflicts and adapter unsupported errors keep standard gRPC status', async t => {
94
+ const { adapter, service, close } = await createRpcFixture()
95
+ t.teardown(close)
96
+ const snapshot = sinon.stub(adapter, 'roomMemberSnapshot').resolves({ roomId: 'room', revision: '2', members: [] })
97
+ await t.rejects(service.roomMemberSnapshot('room', '1'), { code: grpc.status.FAILED_PRECONDITION })
98
+ snapshot.resolves({ roomId: 'other', revision: '1', members: [] })
99
+ await t.rejects(service.roomMemberSnapshot('room', '1'), { code: grpc.status.FAILED_PRECONDITION })
100
+ snapshot.rejects(Object.assign(new Error('snapshot not loaded'), { code: grpc.status.FAILED_PRECONDITION }))
101
+ await t.rejects(service.roomMemberSnapshot('room', '1'), { code: grpc.status.FAILED_PRECONDITION })
102
+ await t.rejects(service.roomMemberSync('room'), { code: grpc.status.UNIMPLEMENTED })
103
+ await t.rejects(service.roomMemberLoadState('room'), { code: grpc.status.UNIMPLEMENTED })
104
+ snapshot.restore()
105
+ await t.rejects(service.roomMemberSnapshot('room', '1'), { code: grpc.status.UNIMPLEMENTED })
106
+ })
107
+
108
+ async function deliverEvent (service: PuppetService, event: grpcPuppet.EventResponse): Promise<void> {
109
+ const handler: unknown = Reflect.get(service, 'onGrpcStreamEvent')
110
+ if (typeof handler !== 'function') { throw new Error('Missing event handler') }
111
+ await handler.call(service, event)
112
+ }
113
+
114
+ test('independent event waits for both persistent stores then invalidates base caches', async t => {
115
+ const service = new PuppetService({ token: 'load-event-test' })
116
+ await service.cache.start()
117
+ t.teardown(() => service.cache.stop())
118
+ service.cache.room?.set('room', { id: 'room', topic: '', adminIdList: [], memberIdList: [] })
119
+ service.cache.roomMember?.set('room', { member: { id: 'member', name: '', avatar: '' } })
120
+ service.on('room-member-load', service.onRoomMemberLoad.bind(service))
121
+ let finishRoom: () => void = () => { throw new Error('Missing room resolver') }
122
+ let finishMembers: () => void = () => { throw new Error('Missing members resolver') }
123
+ const roomDeleted = new Promise<void>(resolve => { finishRoom = resolve })
124
+ const membersDeleted = new Promise<void>(resolve => { finishMembers = resolve })
125
+ const deleteRoom = sinon.stub().returns(roomDeleted)
126
+ const deleteMembers = sinon.stub().returns(membersDeleted)
127
+ Object.defineProperty(service, '_payloadStore', {
128
+ value: { room: { delete: deleteRoom }, roomMember: { delete: deleteMembers } },
129
+ })
130
+ const dirty = sinon.spy(service, 'dirtyPayload')
131
+ const dirtyEvent = sinon.spy()
132
+ const load = sinon.spy()
133
+ service.on('dirty', dirtyEvent)
134
+ service.on('room-member-load', load)
135
+ const event = new grpcPuppet.EventResponse()
136
+ event.setType(grpcPuppet.EventType.EVENT_TYPE_ROOM_MEMBER_LOAD)
137
+ event.setPayload(JSON.stringify(state))
138
+ const pending = deliverEvent(service, event)
139
+ t.equal(load.callCount, 0)
140
+ finishRoom()
141
+ await Promise.resolve()
142
+ t.equal(load.callCount, 0, 'second store still pending')
143
+ finishMembers()
144
+ await pending
145
+ t.same(deleteRoom.args[0], [ 'room' ])
146
+ t.same(deleteMembers.args[0], [ 'room' ])
147
+ t.same(load.args[0], [ state ])
148
+ t.equal(service.cache.room?.has('room'), false)
149
+ t.equal(service.cache.roomMember?.has('room'), false)
150
+ t.equal(dirty.callCount, 0)
151
+ t.equal(dirtyEvent.callCount, 0)
152
+ t.equal(EventTypeRev[39], 'EVENT_TYPE_ROOM_MEMBER_LOAD')
153
+ })
154
+
155
+ test('server forwards original room-member-load event and removes its listener', async t => {
156
+ const adapter = new PuppetMock() as unknown as PUPPET.impls.PuppetInterface
157
+ const manager = new EventStreamManager(adapter)
158
+ const emit = sinon.stub(manager, 'grpcEmit')
159
+ manager.connectPuppetEventToStreamingCall()
160
+ adapter.emit('room-member-load', state)
161
+ t.same(emit.args[0], [ grpcPuppet.EventType.EVENT_TYPE_ROOM_MEMBER_LOAD, state ])
162
+ manager.removePuppetListeners()
163
+ adapter.emit('room-member-load', state)
164
+ t.equal(emit.callCount, 1)
165
+ })
166
+
167
+ test('single and batch room payload RPCs retain every new field', async t => {
168
+ const { adapter, service, close } = await createRpcFixture()
169
+ t.teardown(close)
170
+ const payload: PUPPET.payloads.Room = {
171
+ id: 'room',
172
+ topic: 'topic',
173
+ avatar: 'avatar',
174
+ handle: 'handle',
175
+ ownerId: 'owner',
176
+ adminIdList: [ 'admin' ],
177
+ memberIdList: [],
178
+ additionalInfo: 'info',
179
+ remark: 'remark',
180
+ external: true,
181
+ createTime: 1234,
182
+ memberCount: 8,
183
+ memberLoadMode: 2,
184
+ memberLoadStatus: 3,
185
+ memberLoadRevision: state.revision,
186
+ memberLoadedAt: 0,
187
+ }
188
+ sinon.stub(adapter, 'roomPayload').resolves(payload)
189
+ sinon.stub(adapter, 'batchRoomPayload').resolves(new Map([ [ 'room', payload ] ]))
190
+ t.same(await service.roomRawPayload('room'), payload)
191
+ t.same((await service.batchRoomRawPayload([ 'room' ])).get('room'), payload)
192
+ })
193
+
194
+ test('malformed status and revision fail rather than falling back to dirty', async t => {
195
+ const { adapter, service, close } = await createRpcFixture()
196
+ t.teardown(close)
197
+ const read = sinon.stub(adapter, 'roomMemberLoadState').resolves({ ...state, status: 99 })
198
+ const dirty = sinon.spy(adapter, 'dirtyPayload')
199
+ await t.rejects(service.roomMemberLoadState('room'), /status/)
200
+ read.resolves({ ...state, revision: '01' })
201
+ await t.rejects(service.roomMemberLoadState('room'), /revision/)
202
+ read.resolves({ ...state, roomId: 'other' })
203
+ await t.rejects(service.roomMemberLoadState('room'), /roomId/)
204
+ t.equal(dirty.callCount, 0)
205
+ })
206
+
207
+ test('cache deletion failure rejects without announcing a successful load', async t => {
208
+ const service = new PuppetService({ token: 'load-event-failure' })
209
+ Object.defineProperty(service, '_payloadStore', {
210
+ value: {
211
+ room: { delete: async (): Promise<void> => { throw new Error('cache unavailable') } },
212
+ roomMember: { delete: async (): Promise<void> => {} },
213
+ },
214
+ })
215
+ const load = sinon.spy()
216
+ service.on('room-member-load', load)
217
+ const event = new grpcPuppet.EventResponse()
218
+ event.setType(grpcPuppet.EventType.EVENT_TYPE_ROOM_MEMBER_LOAD)
219
+ event.setPayload(JSON.stringify(state))
220
+ await t.rejects(deliverEvent(service, event), /cache unavailable/)
221
+ t.equal(load.callCount, 0)
222
+ })
223
+
224
+ test('server enforces snapshot request bounds before invoking the adapter', async t => {
225
+ const { adapter, client, close } = await createRpcFixture()
226
+ t.teardown(close)
227
+ const snapshot = sinon.stub(adapter, 'roomMemberSnapshot').resolves({ roomId: 'room', revision: '1', members: [] })
228
+ const request = new grpcPuppet.RoomMemberSnapshotRequest()
229
+ request.setId('room')
230
+ request.setRevision('1')
231
+ const invoke = util.promisify(client.roomMemberSnapshot.bind(client))
232
+ request.setPageSize(501)
233
+ await t.rejects(invoke(request), { code: grpc.status.INVALID_ARGUMENT })
234
+ request.setPageSize(0)
235
+ request.setRevision('01')
236
+ await t.rejects(invoke(request), { code: grpc.status.INVALID_ARGUMENT })
237
+ request.setRevision('1')
238
+ request.setId('')
239
+ await t.rejects(invoke(request), { code: grpc.status.INVALID_ARGUMENT })
240
+ t.equal(snapshot.callCount, 0)
241
+ request.setId('room')
242
+ await invoke(request)
243
+ t.same(snapshot.args[0], [ 'room', '1', { pageToken: '', pageSize: 0 } ])
244
+ request.setPageSize(500)
245
+ await invoke(request)
246
+ t.same(snapshot.args[1], [ 'room', '1', { pageToken: '', pageSize: 500 } ])
247
+ })
248
+
249
+ test('all three RPCs preserve adapter error codes', async t => {
250
+ const { adapter, service, close } = await createRpcFixture()
251
+ t.teardown(close)
252
+ const error = Object.assign(new Error('adapter capacity exhausted'), { code: grpc.status.RESOURCE_EXHAUSTED })
253
+ sinon.stub(adapter, 'roomMemberSync').rejects(error)
254
+ sinon.stub(adapter, 'roomMemberLoadState').rejects(error)
255
+ sinon.stub(adapter, 'roomMemberSnapshot').rejects(error)
256
+ await t.rejects(service.roomMemberSync('room'), { code: grpc.status.RESOURCE_EXHAUSTED })
257
+ await t.rejects(service.roomMemberLoadState('room'), { code: grpc.status.RESOURCE_EXHAUSTED })
258
+ await t.rejects(service.roomMemberSnapshot('room', '1'), { code: grpc.status.RESOURCE_EXHAUSTED })
259
+ })
260
+
261
+ test('legacy adapter without member loading methods returns UNIMPLEMENTED', async t => {
262
+ const legacy = new PuppetMock() as unknown as PUPPET.impls.PuppetInterface
263
+ Object.defineProperties(legacy, {
264
+ roomMemberSync: { value: undefined },
265
+ roomMemberLoadState: { value: undefined },
266
+ roomMemberSnapshot: { value: undefined },
267
+ })
268
+ const { service, close } = await createRpcFixture(legacy)
269
+ t.teardown(close)
270
+ t.equal(typeof legacy.roomMemberSync, 'undefined')
271
+ t.equal(typeof legacy.roomMemberLoadState, 'undefined')
272
+ t.equal(typeof legacy.roomMemberSnapshot, 'undefined')
273
+ await t.rejects(service.roomMemberSync('room'), { code: grpc.status.UNIMPLEMENTED })
274
+ await t.rejects(service.roomMemberLoadState('room'), { code: grpc.status.UNIMPLEMENTED })
275
+ await t.rejects(service.roomMemberSnapshot('room', '1'), { code: grpc.status.UNIMPLEMENTED })
276
+ })
277
+
278
+ test('plain adapter status objects retain their original details', async t => {
279
+ const { adapter, service, close } = await createRpcFixture()
280
+ t.teardown(close)
281
+ const logs = sinon.spy(PUPPET.log, 'error')
282
+ t.teardown(() => logs.restore())
283
+ sinon.stub(adapter, 'roomMemberSnapshot').rejects({ code: grpc.status.FAILED_PRECONDITION, details: 'revision retired' })
284
+ await t.rejects(service.roomMemberSnapshot('room', '1'), { code: grpc.status.FAILED_PRECONDITION, details: 'revision retired' })
285
+ t.equal(logs.args.some(args => args.includes('roomMemberSnapshot') && args.some(value => typeof value === 'string' && value.includes('revision retired'))), true)
286
+ })
287
+
288
+ test('server event validator rejects invalid state without forwarding a load event', async t => {
289
+ const adapter = new PuppetMock() as unknown as PUPPET.impls.PuppetInterface
290
+ const manager = new EventStreamManager(adapter)
291
+ const emit = sinon.stub(manager, 'grpcEmit')
292
+ const errors = sinon.spy()
293
+ adapter.on('error', errors)
294
+ manager.connectPuppetEventToStreamingCall()
295
+ adapter.emit('room-member-load', { ...state, status: 99 })
296
+ t.equal(errors.callCount, 1)
297
+ t.equal(emit.args.some(args => args[0] === grpcPuppet.EventType.EVENT_TYPE_ROOM_MEMBER_LOAD), false)
298
+ manager.removePuppetListeners()
299
+ })
300
+
301
+ test('persistent batch cache failure rejects without retrying raw RPCs', async t => {
302
+ const { adapter, service, close } = await createRpcFixture()
303
+ t.teardown(close)
304
+ const room: PUPPET.payloads.Room = { id: 'room', topic: '', adminIdList: [], memberIdList: [] }
305
+ const member: PUPPET.payloads.RoomMember = { id: 'member', name: '', avatar: '' }
306
+ sinon.stub(adapter, 'batchRoomPayload').resolves(new Map([ [ 'room', room ] ]))
307
+ sinon.stub(adapter, 'batchRoomMemberPayload').resolves(new Map([ [ 'member', member ] ]))
308
+ const roomFallback = sinon.spy(service, 'roomRawPayload')
309
+ const memberFallback = sinon.spy(service, 'roomMemberRawPayload')
310
+ const store = {
311
+ get: async (): Promise<undefined> => undefined,
312
+ set: async (): Promise<void> => { throw new Error('persistent cache unavailable') },
313
+ }
314
+ Object.defineProperty(service, '_payloadStore', { value: { room: store, roomMember: store } })
315
+ await t.rejects(service.batchRoomRawPayload([ 'room' ]), /persistent cache unavailable/)
316
+ await t.rejects(service.batchRoomMemberRawPayload('room', [ 'member' ]), /persistent cache unavailable/)
317
+ t.equal(roomFallback.callCount, 0)
318
+ t.equal(memberFallback.callCount, 0)
319
+ })
@@ -4,7 +4,7 @@
4
4
  import type { PackageJson } from 'type-fest'
5
5
  export const packageJson: PackageJson = {
6
6
  "name": "@juzi/wechaty-puppet-service",
7
- "version": "1.0.130",
7
+ "version": "1.0.131",
8
8
  "description": "Puppet Service for Wechaty",
9
9
  "type": "module",
10
10
  "exports": {
@@ -55,7 +55,7 @@ export const packageJson: PackageJson = {
55
55
  "@chatie/eslint-config": "^1.0.4",
56
56
  "@chatie/semver": "^0.4.7",
57
57
  "@chatie/tsconfig": "^4.6.3",
58
- "@juzi/wechaty-puppet": "^1.0.157",
58
+ "@juzi/wechaty-puppet": "^1.0.158",
59
59
  "@juzi/wechaty-puppet-mock": "^1.0.1",
60
60
  "@swc/core": "1.3.39",
61
61
  "@types/google-protobuf": "^3.15.5",
@@ -75,10 +75,10 @@ export const packageJson: PackageJson = {
75
75
  "why-is-node-running": "^2.2.1"
76
76
  },
77
77
  "peerDependencies": {
78
- "@juzi/wechaty-puppet": "^1.0.157"
78
+ "@juzi/wechaty-puppet": "^1.0.158"
79
79
  },
80
80
  "dependencies": {
81
- "@juzi/wechaty-grpc": "^1.0.109",
81
+ "@juzi/wechaty-grpc": "^1.0.110",
82
82
  "clone-class": "^1.1.1",
83
83
  "ducks": "^1.0.2",
84
84
  "file-box": "^1.5.5",
@@ -23,6 +23,7 @@ import {
23
23
  } from '@juzi/wechaty-grpc'
24
24
 
25
25
  import * as PUPPET from '@juzi/wechaty-puppet'
26
+ import { validateRoomMemberLoadState } from '../utils/pb-payload-helper.js'
26
27
 
27
28
  import { log, NO_LOG_EVENTS } from '../config.js'
28
29
  import {
@@ -245,6 +246,20 @@ class EventStreamManager {
245
246
  this.offCallbackList.push(off)
246
247
  break
247
248
  }
249
+ case 'room-member-load': {
250
+ const listener = (payload: PUPPET.payloads.EventRoomMemberLoad): void => {
251
+ try {
252
+ validateRoomMemberLoadState(payload)
253
+ this.grpcEmit(grpcPuppet.EventType.EVENT_TYPE_ROOM_MEMBER_LOAD, payload)
254
+ } catch (error) {
255
+ log.error('EventStreamManager', 'room-member-load rejected: %s', error)
256
+ this.puppet.emit('error', error)
257
+ }
258
+ }
259
+ this.puppet.on('room-member-load', listener)
260
+ this.offCallbackList.push(() => this.puppet.off('room-member-load', listener))
261
+ break
262
+ }
248
263
  case 'room-join': {
249
264
  const listener = (payload: PUPPET.payloads.EventRoomJoin) => this.grpcEmit(grpcPuppet.EventType.EVENT_TYPE_ROOM_JOIN, payload)
250
265
  this.puppet.on('room-join', listener)
@@ -20,6 +20,7 @@
20
20
  /* eslint-disable sort-keys */
21
21
  /* eslint-disable @typescript-eslint/no-misused-promises */
22
22
  import type { Writable } from 'stream'
23
+ import { inspect } from 'util'
23
24
  import {
24
25
  chunkDecoder,
25
26
  chunkEncoder,
@@ -43,12 +44,33 @@ import {
43
44
  import { log } from '../config.js'
44
45
  import { grpcError } from './grpc-error.js'
45
46
  import { EventStreamManager } from './event-stream-manager.js'
46
- import { OptionalBooleanUnwrapper, OptionalBooleanWrapper, callRecordPayloadToPb, channelPayloadToPb, channelPbToPayload, chatHistoryPayloadToPb, postPbToPayload, urlLinkPayloadToPb, channelCardPayloadToPb } from '../utils/pb-payload-helper.js'
47
+ import { OptionalBooleanUnwrapper, OptionalBooleanWrapper, callRecordPayloadToPb, channelPayloadToPb, channelPbToPayload, chatHistoryPayloadToPb, postPbToPayload, urlLinkPayloadToPb, channelCardPayloadToPb, roomPayloadToPb, roomMemberLoadStateToPb, roomMemberSnapshotToPb, validateRoomMemberLoadState, validateRoomMemberSnapshot, validateRoomMemberRevision } from '../utils/pb-payload-helper.js'
47
48
  import { grpcCallTypeToPuppetMedia, puppetCallMediaTypeToGrpc } from '../utils/call-media-mapping.js'
48
49
  import { TextContentType } from '@juzi/wechaty-puppet/types'
49
50
 
50
51
  type MessageBatchSendResponse = Awaited<ReturnType<PUPPET.impls.PuppetInterface['messageBatchSendText']>>
51
52
 
53
+ function roomMemberRpcError<Response> (
54
+ method: string, error: unknown, callback: grpc.sendUnaryData<Response>,
55
+ ): void {
56
+ log.error('PuppetServiceImpl', 'roomMemberRpcError() %s() rejection: %s', method, inspect(error))
57
+ if (typeof error === 'object' && error !== null && 'code' in error) {
58
+ const status = error as Record<string, unknown>
59
+ const code = status['code']
60
+ if (typeof code === 'number' && Number.isInteger(code) && code > 0 && code <= 16) {
61
+ const details = typeof status['details'] === 'string' ? status['details']
62
+ : error instanceof Error ? error.message : 'Room member RPC failed'
63
+ callback({ code, details }, null)
64
+ return
65
+ }
66
+ }
67
+ if (error instanceof Error && /unsupported/i.test(error.message)) {
68
+ callback({ code: grpc.status.UNIMPLEMENTED, details: error.message }, null)
69
+ return
70
+ }
71
+ grpcError(method, error, callback)
72
+ }
73
+
52
74
  type GrpcBatchResult = {
53
75
  setConversationId(value: string): void,
54
76
  setError(value: string): void,
@@ -1996,6 +2018,75 @@ function puppetImplementation (
1996
2018
  }
1997
2019
  },
1998
2020
 
2021
+ roomMemberSync: async (
2022
+ call: grpc.ServerUnaryCall<grpcPuppet.RoomMemberSyncRequest, grpcPuppet.RoomMemberLoadStateResponse>,
2023
+ callback: grpc.sendUnaryData<grpcPuppet.RoomMemberLoadStateResponse>,
2024
+ ): Promise<void> => {
2025
+ try {
2026
+ const roomId = call.request.getId()
2027
+ if (!roomId) {
2028
+ return callback({ code: grpc.status.INVALID_ARGUMENT, details: 'roomId is required' }, null)
2029
+ }
2030
+ const options = call.request.getForce() ? { force: true } : undefined
2031
+ if (typeof puppet.roomMemberSync !== 'function') { throw new Error('roomMemberSync unsupported by adapter') }
2032
+ const state = await puppet.roomMemberSync(roomId, options)
2033
+ validateRoomMemberLoadState(state, roomId)
2034
+ return callback(null, roomMemberLoadStateToPb(state))
2035
+ } catch (error) {
2036
+ return roomMemberRpcError('roomMemberSync', error, callback)
2037
+ }
2038
+ },
2039
+
2040
+ roomMemberLoadState: async (
2041
+ call: grpc.ServerUnaryCall<grpcPuppet.RoomMemberLoadStateRequest, grpcPuppet.RoomMemberLoadStateResponse>,
2042
+ callback: grpc.sendUnaryData<grpcPuppet.RoomMemberLoadStateResponse>,
2043
+ ): Promise<void> => {
2044
+ try {
2045
+ const roomId = call.request.getId()
2046
+ if (!roomId) {
2047
+ return callback({ code: grpc.status.INVALID_ARGUMENT, details: 'roomId is required' }, null)
2048
+ }
2049
+ if (typeof puppet.roomMemberLoadState !== 'function') { throw new Error('roomMemberLoadState unsupported by adapter') }
2050
+ const state = await puppet.roomMemberLoadState(roomId)
2051
+ validateRoomMemberLoadState(state, roomId)
2052
+ return callback(null, roomMemberLoadStateToPb(state))
2053
+ } catch (error) {
2054
+ return roomMemberRpcError('roomMemberLoadState', error, callback)
2055
+ }
2056
+ },
2057
+
2058
+ roomMemberSnapshot: async (
2059
+ call: grpc.ServerUnaryCall<grpcPuppet.RoomMemberSnapshotRequest, grpcPuppet.RoomMemberSnapshotResponse>,
2060
+ callback: grpc.sendUnaryData<grpcPuppet.RoomMemberSnapshotResponse>,
2061
+ ): Promise<void> => {
2062
+ try {
2063
+ const roomId = call.request.getId()
2064
+ const revision = call.request.getRevision()
2065
+ const pageSize = call.request.getPageSize()
2066
+ try {
2067
+ if (!roomId) { throw new Error('roomId is required') }
2068
+ validateRoomMemberRevision(revision)
2069
+ if (!Number.isInteger(pageSize) || pageSize < 0 || pageSize > 500) {
2070
+ throw new Error('pageSize must be an integer between 0 and 500')
2071
+ }
2072
+ } catch (error) {
2073
+ return callback({ code: grpc.status.INVALID_ARGUMENT, details: (error as Error).message }, null)
2074
+ }
2075
+ if (typeof puppet.roomMemberSnapshot !== 'function') { throw new Error('roomMemberSnapshot unsupported by adapter') }
2076
+ const snapshot = await puppet.roomMemberSnapshot(roomId, revision, {
2077
+ pageToken: call.request.getPageToken(), pageSize,
2078
+ })
2079
+ try {
2080
+ validateRoomMemberSnapshot(snapshot, roomId, revision)
2081
+ } catch (error) {
2082
+ return callback({ code: grpc.status.FAILED_PRECONDITION, details: (error as Error).message }, null)
2083
+ }
2084
+ return callback(null, roomMemberSnapshotToPb(snapshot))
2085
+ } catch (error) {
2086
+ return roomMemberRpcError('roomMemberSnapshot', error, callback)
2087
+ }
2088
+ },
2089
+
1999
2090
  roomMemberList: async (call, callback) => {
2000
2091
  log.verbose('PuppetServiceImpl', 'roomMemberList()')
2001
2092
 
@@ -2085,20 +2176,7 @@ function puppetImplementation (
2085
2176
 
2086
2177
  const payload = await puppet.roomPayload(roomId)
2087
2178
 
2088
- const response = new grpcPuppet.RoomPayloadResponse()
2089
- response.setAdminIdsList(payload.adminIdList)
2090
- response.setAvatar(payload.avatar || '')
2091
- response.setHandle(payload.handle || '')
2092
- response.setId(payload.id)
2093
- response.setMemberIdsList(payload.memberIdList)
2094
- response.setOwnerId(payload.ownerId || '')
2095
- response.setTopic(payload.topic)
2096
- response.setAdditionalInfo(payload.additionalInfo || '')
2097
- response.setRoomRemark(payload.remark || '')
2098
- response.setExternal(!!payload.external)
2099
- if (payload.createTime) {
2100
- response.setCreateTime(timestampFromMilliseconds(payload.createTime))
2101
- }
2179
+ const response = roomPayloadToPb(payload)
2102
2180
 
2103
2181
  return callback(null, response)
2104
2182
 
@@ -2119,20 +2197,7 @@ function puppetImplementation (
2119
2197
 
2120
2198
  const payloads: grpcPuppet.RoomPayloadResponse[] = []
2121
2199
  for (const [ _, payload ] of payloadMap.entries()) {
2122
- const pb = new grpcPuppet.RoomPayloadResponse()
2123
- pb.setAdminIdsList(payload.adminIdList)
2124
- pb.setAvatar(payload.avatar || '')
2125
- pb.setHandle(payload.handle || '')
2126
- pb.setId(payload.id)
2127
- pb.setMemberIdsList(payload.memberIdList)
2128
- pb.setOwnerId(payload.ownerId || '')
2129
- pb.setTopic(payload.topic)
2130
- pb.setAdditionalInfo(payload.additionalInfo || '')
2131
- pb.setRoomRemark(payload.remark || '')
2132
- pb.setExternal(!!payload.external)
2133
- if (payload.createTime) {
2134
- pb.setCreateTime(timestampFromMilliseconds(payload.createTime))
2135
- }
2200
+ const pb = roomPayloadToPb(payload)
2136
2201
  payloads.push(pb)
2137
2202
  }
2138
2203