@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.
- package/dist/cjs/src/auth/call-cred.d.ts +2 -2
- package/dist/cjs/src/auth/call-cred.d.ts.map +1 -1
- package/dist/cjs/src/auth/call-cred.js.map +1 -1
- package/dist/cjs/src/client/puppet-service.d.ts +25 -2
- package/dist/cjs/src/client/puppet-service.d.ts.map +1 -1
- package/dist/cjs/src/client/puppet-service.js +389 -124
- package/dist/cjs/src/client/puppet-service.js.map +1 -1
- package/dist/cjs/src/client/room-cache-coordinator.d.ts +14 -0
- package/dist/cjs/src/client/room-cache-coordinator.d.ts.map +1 -0
- package/dist/cjs/src/client/room-cache-coordinator.js +53 -0
- package/dist/cjs/src/client/room-cache-coordinator.js.map +1 -0
- package/dist/cjs/src/client/room-member-cache.spec.d.ts +2 -0
- package/dist/cjs/src/client/room-member-cache.spec.d.ts.map +1 -0
- package/dist/cjs/src/client/room-member-cache.spec.js +417 -0
- package/dist/cjs/src/client/room-member-cache.spec.js.map +1 -0
- package/dist/cjs/src/client/room-member-load.spec.d.ts +2 -0
- package/dist/cjs/src/client/room-member-load.spec.d.ts.map +1 -0
- package/dist/cjs/src/client/room-member-load.spec.js +324 -0
- package/dist/cjs/src/client/room-member-load.spec.js.map +1 -0
- package/dist/cjs/src/package-json.js +4 -4
- package/dist/cjs/src/server/event-stream-manager.d.ts.map +1 -1
- package/dist/cjs/src/server/event-stream-manager.js +16 -0
- package/dist/cjs/src/server/event-stream-manager.js.map +1 -1
- package/dist/cjs/src/server/puppet-implementation.d.ts.map +1 -1
- package/dist/cjs/src/server/puppet-implementation.js +91 -28
- package/dist/cjs/src/server/puppet-implementation.js.map +1 -1
- package/dist/cjs/src/utils/pb-payload-helper.d.ts +10 -0
- package/dist/cjs/src/utils/pb-payload-helper.d.ts.map +1 -1
- package/dist/cjs/src/utils/pb-payload-helper.js +180 -1
- package/dist/cjs/src/utils/pb-payload-helper.js.map +1 -1
- package/dist/cjs/src/utils/room-member-load.spec.d.ts +2 -0
- package/dist/cjs/src/utils/room-member-load.spec.d.ts.map +1 -0
- package/dist/cjs/src/utils/room-member-load.spec.js +83 -0
- package/dist/cjs/src/utils/room-member-load.spec.js.map +1 -0
- package/dist/esm/src/auth/call-cred.d.ts +2 -2
- package/dist/esm/src/auth/call-cred.d.ts.map +1 -1
- package/dist/esm/src/auth/call-cred.js.map +1 -1
- package/dist/esm/src/client/puppet-service.d.ts +25 -2
- package/dist/esm/src/client/puppet-service.d.ts.map +1 -1
- package/dist/esm/src/client/puppet-service.js +390 -125
- package/dist/esm/src/client/puppet-service.js.map +1 -1
- package/dist/esm/src/client/room-cache-coordinator.d.ts +14 -0
- package/dist/esm/src/client/room-cache-coordinator.d.ts.map +1 -0
- package/dist/esm/src/client/room-cache-coordinator.js +49 -0
- package/dist/esm/src/client/room-cache-coordinator.js.map +1 -0
- package/dist/esm/src/client/room-member-cache.spec.d.ts +2 -0
- package/dist/esm/src/client/room-member-cache.spec.d.ts.map +1 -0
- package/dist/esm/src/client/room-member-cache.spec.js +415 -0
- package/dist/esm/src/client/room-member-cache.spec.js.map +1 -0
- package/dist/esm/src/client/room-member-load.spec.d.ts +2 -0
- package/dist/esm/src/client/room-member-load.spec.d.ts.map +1 -0
- package/dist/esm/src/client/room-member-load.spec.js +296 -0
- package/dist/esm/src/client/room-member-load.spec.js.map +1 -0
- package/dist/esm/src/package-json.js +4 -4
- package/dist/esm/src/server/event-stream-manager.d.ts.map +1 -1
- package/dist/esm/src/server/event-stream-manager.js +16 -0
- package/dist/esm/src/server/event-stream-manager.js.map +1 -1
- package/dist/esm/src/server/puppet-implementation.d.ts.map +1 -1
- package/dist/esm/src/server/puppet-implementation.js +92 -29
- package/dist/esm/src/server/puppet-implementation.js.map +1 -1
- package/dist/esm/src/utils/pb-payload-helper.d.ts +10 -0
- package/dist/esm/src/utils/pb-payload-helper.d.ts.map +1 -1
- package/dist/esm/src/utils/pb-payload-helper.js +169 -0
- package/dist/esm/src/utils/pb-payload-helper.js.map +1 -1
- package/dist/esm/src/utils/room-member-load.spec.d.ts +2 -0
- package/dist/esm/src/utils/room-member-load.spec.d.ts.map +1 -0
- package/dist/esm/src/utils/room-member-load.spec.js +58 -0
- package/dist/esm/src/utils/room-member-load.spec.js.map +1 -0
- package/package.json +4 -4
- package/src/auth/call-cred.ts +1 -1
- package/src/client/puppet-service.ts +324 -138
- package/src/client/room-cache-coordinator.ts +50 -0
- package/src/client/room-member-cache.spec.ts +448 -0
- package/src/client/room-member-load.spec.ts +319 -0
- package/src/package-json.ts +4 -4
- package/src/server/event-stream-manager.ts +15 -0
- package/src/server/puppet-implementation.ts +94 -29
- package/src/utils/pb-payload-helper.ts +150 -0
- 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
|
+
})
|
package/src/package-json.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
78
|
+
"@juzi/wechaty-puppet": "^1.0.158"
|
|
79
79
|
},
|
|
80
80
|
"dependencies": {
|
|
81
|
-
"@juzi/wechaty-grpc": "^1.0.
|
|
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 =
|
|
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 =
|
|
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
|
|