koishi-plugin-chat-patch 5.5.0 → 5.6.0

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 (44) hide show
  1. package/client/vue/index.vue +8 -1
  2. package/client/web/dist/assets/{Chat-DTEsebnY.css → Chat-BXVWj5Bd.css} +1 -1
  3. package/client/web/dist/assets/{Chat-Clqxn7CB.js → Chat-m4-U5uHa.js} +4 -4
  4. package/client/web/dist/assets/{MsgBody-C0TKsXhC.js → MsgBody-CzrxyiFO.js} +1 -1
  5. package/client/web/dist/assets/MsgBody.vue_vue_type_script_setup_true_lang-BBF1uxm6.js +67 -0
  6. package/client/web/dist/assets/{index-BA37EQGa.js → index-EJ2CcODr.js} +72 -78
  7. package/client/web/dist/assets/index-X9hFOAza.css +1 -0
  8. package/client/web/dist/index.html +2 -2
  9. package/client/web/src/App.vue +7 -0
  10. package/client/web/src/assets/css/chat.css +6 -4
  11. package/client/web/src/assets/l10n/zh-CN.po +0 -6
  12. package/client/web/src/components/History.vue +27 -0
  13. package/client/web/src/components/MsgBody.vue +18 -9
  14. package/client/web/src/function/connect.ts +208 -46
  15. package/client/web/src/function/msg.ts +8 -6
  16. package/client/web/src/function/option.ts +1 -1
  17. package/client/web/src/function/satori-model.ts +20 -2
  18. package/client/web/src/function/satori.ts +64 -0
  19. package/client/web/src/pages/Chat.vue +0 -4
  20. package/client/web/src/pages/Friends.vue +7 -9
  21. package/client/web/src/pages/Messages.vue +6 -7
  22. package/client/web/src/pages/options/OptDev.vue +1 -25
  23. package/client/web/src/pages/options/OptFunction.vue +23 -0
  24. package/client/web/tsconfig.tsbuildinfo +1 -1
  25. package/dist/index.js +1 -1
  26. package/dist/style.css +1 -1
  27. package/lib/bootstrap.d.ts +6 -0
  28. package/lib/database.d.ts +5 -2
  29. package/lib/gateway.d.ts +46 -0
  30. package/lib/index.js +1468 -131
  31. package/lib/recorder.d.ts +3 -5
  32. package/lib/satori.d.ts +3 -0
  33. package/lib/types.d.ts +17 -0
  34. package/package.json +4 -1
  35. package/src/bootstrap.ts +5 -10
  36. package/src/database.ts +745 -662
  37. package/src/gateway.ts +259 -0
  38. package/src/index.ts +63 -53
  39. package/src/recorder.ts +97 -77
  40. package/src/satori.ts +19 -0
  41. package/src/server.d.ts +26 -26
  42. package/src/types.ts +19 -0
  43. package/client/web/dist/assets/MsgBody.vue_vue_type_script_setup_true_lang-CCdBwlN_.js +0 -67
  44. package/client/web/dist/assets/index-B71pPwae.css +0 -1
package/src/gateway.ts ADDED
@@ -0,0 +1,259 @@
1
+ import { Context } from 'koishi'
2
+ import { Opcode } from '@satorijs/protocol'
3
+ import {} from '@koishijs/plugin-http'
4
+ import {} from '@koishijs/plugin-server'
5
+ import {} from '@satorijs/plugin-server'
6
+
7
+ import { Config } from './config'
8
+ import { ChatDatabase } from './database'
9
+ import { PluginLogger } from './logger'
10
+ import { Recorder } from './recorder'
11
+ import { resolveSatoriEndpoint, toSatoriEventUrl } from './satori'
12
+ import { SatoriEventPayload, SatoriLoginInfo } from './types'
13
+
14
+ const WS_OPEN = 1
15
+ const RECONNECT_MAX_DELAY = 30000
16
+
17
+ export type SatoriGatewayPayload =
18
+ | { kind: 'ready'; logins: SatoriLoginInfo[] }
19
+ | { kind: 'event'; event: SatoriEventPayload }
20
+ | { kind: 'status'; online: boolean }
21
+
22
+ type SatoriGatewayHandler = (payload: SatoriGatewayPayload) => void
23
+
24
+ function getObject(value: unknown): Record<string, unknown> {
25
+ return typeof value === 'object' && value !== null
26
+ ? value as Record<string, unknown>
27
+ : {}
28
+ }
29
+
30
+ function getString(value: unknown): string {
31
+ return typeof value === 'string' ? value : ''
32
+ }
33
+
34
+ function getNumber(value: unknown): number {
35
+ const num = Number(value)
36
+ return Number.isFinite(num) ? num : 0
37
+ }
38
+
39
+ export class SatoriGateway {
40
+ private socket?: WebSocket
41
+ private pingDispose?: () => void
42
+ private reconnectDispose?: () => void
43
+ private stopped = true
44
+ private retry = 0
45
+ private sequence = 0
46
+ private logins: SatoriLoginInfo[] = []
47
+ private online = false
48
+
49
+ constructor(
50
+ private ctx: Context,
51
+ private config: Config,
52
+ private database: ChatDatabase,
53
+ private recorder: Recorder,
54
+ private logger: PluginLogger,
55
+ private onPayload: SatoriGatewayHandler,
56
+ ) {}
57
+
58
+ async start() {
59
+ this.stopped = false
60
+ this.retry = 0
61
+ this.sequence = await this.loadSequence()
62
+ this.connect()
63
+ }
64
+
65
+ dispose() {
66
+ this.stopped = true
67
+ this.pingDispose?.()
68
+ this.reconnectDispose?.()
69
+ this.socket?.close()
70
+ this.socket = undefined
71
+ this.setOnline(false)
72
+ }
73
+
74
+ getLogins(): SatoriLoginInfo[] {
75
+ return this.logins.map((item) => ({ ...item }))
76
+ }
77
+
78
+ private async loadSequence(): Promise<number> {
79
+ const value = await this.database.getMeta('satori:sn')
80
+ return getNumber(value)
81
+ }
82
+
83
+ private connect() {
84
+ if (this.stopped) return
85
+ let socket: WebSocket
86
+ try {
87
+ socket = this.ctx.http.ws(toSatoriEventUrl(resolveSatoriEndpoint(this.ctx)))
88
+ } catch (error) {
89
+ this.logger.warn('Satori 连接创建失败:', error)
90
+ this.scheduleReconnect()
91
+ return
92
+ }
93
+ this.socket = socket
94
+
95
+ socket.addEventListener('open', () => {
96
+ if (this.socket !== socket) {
97
+ socket.close()
98
+ return
99
+ }
100
+ this.retry = 0
101
+ this.setOnline(true)
102
+ socket.send(JSON.stringify({
103
+ op: Opcode.IDENTIFY,
104
+ body: {
105
+ token: this.token,
106
+ sn: this.sequence || undefined,
107
+ },
108
+ }))
109
+ if (!this.pingDispose) {
110
+ this.pingDispose = this.ctx.setInterval(() => {
111
+ if (this.socket?.readyState === WS_OPEN) {
112
+ this.socket.send(JSON.stringify({ op: Opcode.PING, body: {} }))
113
+ }
114
+ }, 10000)
115
+ }
116
+ })
117
+
118
+ socket.addEventListener('message', (event) => {
119
+ void this.handleMessage(event.data).catch((error) => {
120
+ this.logger.warn('Satori 事件处理失败:', error)
121
+ })
122
+ })
123
+
124
+ socket.addEventListener('close', () => {
125
+ if (this.socket === socket) this.socket = undefined
126
+ this.pingDispose?.()
127
+ this.pingDispose = undefined
128
+ this.setOnline(false)
129
+ this.scheduleReconnect()
130
+ })
131
+
132
+ socket.addEventListener('error', () => {
133
+ socket.close()
134
+ })
135
+ }
136
+
137
+ private get token(): string {
138
+ return getString(this.ctx.satori?.server?.config?.token)
139
+ }
140
+
141
+ private scheduleReconnect() {
142
+ if (this.stopped || this.reconnectDispose) return
143
+ const delay = Math.min(RECONNECT_MAX_DELAY, 1000 * 2 ** this.retry)
144
+ this.retry += 1
145
+ this.logger.logInfo('Satori 将在', delay, 'ms 后重连')
146
+ this.reconnectDispose = this.ctx.setTimeout(() => {
147
+ this.reconnectDispose = undefined
148
+ this.connect()
149
+ }, delay)
150
+ }
151
+
152
+ private async handleMessage(data: unknown) {
153
+ let payload: { op?: unknown; body?: unknown }
154
+ try {
155
+ payload = JSON.parse(String(data)) as { op?: unknown; body?: unknown }
156
+ } catch {
157
+ this.logger.warn('Satori 消息解析失败')
158
+ return
159
+ }
160
+
161
+ if (payload.op === Opcode.READY) {
162
+ const body = getObject(payload.body)
163
+ this.logins = this.normalizeLogins(body.logins)
164
+ this.onPayload({ kind: 'ready', logins: this.getLogins() })
165
+ return
166
+ }
167
+
168
+ if (payload.op !== Opcode.EVENT) return
169
+
170
+ const body = getObject(payload.body)
171
+ const sn = getNumber(body.sn)
172
+ if (sn) {
173
+ this.sequence = sn
174
+ void this.database.setMeta('satori:sn', sn).catch((error) => {
175
+ this.logger.warn('Satori sequence 保存失败:', error)
176
+ })
177
+ }
178
+
179
+ const type = getString(body.type)
180
+ if (type === 'login-added' || type === 'login-updated') {
181
+ const next = this.normalizeLogins([body.login])
182
+ if (next.length) {
183
+ this.logins = this.logins.filter((item) => {
184
+ return !(item.platform === next[0].platform && item.selfId === next[0].selfId)
185
+ })
186
+ this.logins.push(next[0])
187
+ this.onPayload({ kind: 'ready', logins: this.getLogins() })
188
+ }
189
+ return
190
+ }
191
+ if (type === 'login-removed') {
192
+ const login = getObject(body.login)
193
+ const loginUser = getObject(login.user)
194
+ const platform = getString(body.platform) || getString(login.platform)
195
+ const selfId = getString(body.self_id) || getString(body.selfId) || getString(loginUser.id)
196
+ this.logins = this.logins.filter((item) => {
197
+ return !(item.platform === platform && item.selfId === selfId)
198
+ })
199
+ this.onPayload({ kind: 'ready', logins: this.getLogins() })
200
+ return
201
+ }
202
+
203
+ const login = getObject(body.login)
204
+ const loginUser = getObject(login.user)
205
+ const platform = getString(body.platform) || getString(login.platform)
206
+ const selfId = getString(body.self_id) || getString(body.selfId) || getString(loginUser.id)
207
+ const event: SatoriEventPayload = {
208
+ type: getString(body.type),
209
+ platform,
210
+ selfId,
211
+ timestamp: getNumber(body.timestamp) || Date.now(),
212
+ sn,
213
+ body,
214
+ }
215
+ if (this.isBlocked(platform)) return
216
+
217
+ try {
218
+ await this.recorder.handleEvent(body)
219
+ } catch (error) {
220
+ this.logger.warn('Satori 消息写入失败:', error)
221
+ }
222
+ this.onPayload({ kind: 'event', event })
223
+ }
224
+
225
+ private normalizeLogins(value: unknown): SatoriLoginInfo[] {
226
+ if (!Array.isArray(value)) return []
227
+ const result: SatoriLoginInfo[] = []
228
+ for (const item of value) {
229
+ const login = getObject(item)
230
+ const user = getObject(login.user)
231
+ const platform = getString(login.platform) || getString(user.platform)
232
+ const selfId = getString(login.self_id) || getString(login.selfId) || getString(user.id)
233
+ if (!platform || !selfId || this.isBlocked(platform)) continue
234
+ result.push({
235
+ platform,
236
+ selfId,
237
+ name: getString(user.name) || getString(user.nick) || getString(user.nickname) || selfId,
238
+ avatar: getString(user.avatar) || undefined,
239
+ status: getNumber(login.status),
240
+ features: Array.isArray(login.features) ? login.features.map(String) : [],
241
+ })
242
+ }
243
+ return result
244
+ }
245
+
246
+ private isBlocked(platform: string): boolean {
247
+ return (this.config.blockedPlatforms ?? []).some((item) => {
248
+ return item.exactMatch
249
+ ? platform === item.platformName
250
+ : platform.includes(item.platformName)
251
+ })
252
+ }
253
+
254
+ private setOnline(online: boolean) {
255
+ if (this.online === online) return
256
+ this.online = online
257
+ this.onPayload({ kind: 'status', online })
258
+ }
259
+ }
package/src/index.ts CHANGED
@@ -1,69 +1,79 @@
1
- import { Context } from 'koishi'
2
- import { Console } from '@koishijs/console'
3
- import path from 'node:path'
4
-
5
- import { Config } from './config'
6
- import { createPluginLogger } from './logger'
7
- import { ContactCacheService } from './cache'
8
- import { ChatDatabase } from './database'
1
+ import { Context } from 'koishi'
2
+ import { Console } from '@koishijs/console'
3
+ import path from 'node:path'
4
+
5
+ import { Config } from './config'
6
+ import { createPluginLogger } from './logger'
7
+ import { ContactCacheService } from './cache'
8
+ import { ChatDatabase } from './database'
9
9
  import { Recorder } from './recorder'
10
+ import { SatoriGateway } from './gateway'
10
11
  import { MediaManager } from './media'
11
12
  import { SelfMessageRecorder } from './self-message'
12
13
  import { registerBootstrap } from './bootstrap'
13
14
  import { registerWeb } from './web'
14
-
15
- export const name = 'chat-patch'
16
- export const reusable = false
17
- export const filter = false
15
+
16
+ export const name = 'chat-patch'
17
+ export const reusable = false
18
+ export const filter = false
18
19
  export const inject = {
19
- required: ['console', 'server', 'satori.server'],
20
+ required: ['console', 'server', 'http', 'satori.server'],
20
21
  }
21
-
22
- declare module 'koishi' {
23
- interface Context {
24
- console: Console
25
- }
26
- }
27
-
28
- export const usage = `
29
- ---
30
-
31
- 基于 Satori 协议的 Koishi 后台聊天室。
32
- 需要在 Koishi 中启用 server-satori,并在插件内构建 web 应用。
33
-
34
- ---
35
- `
36
-
37
- export { Config } from './config'
38
-
39
- export async function apply(ctx: Context, config: Config) {
40
- const pluginLogger = createPluginLogger(ctx.logger('chat-patch'), config)
41
-
42
- const database = new ChatDatabase(ctx, config, pluginLogger)
43
- await database.initialize()
44
- const contactCache = new ContactCacheService(ctx, database, pluginLogger)
45
-
46
- const media = new MediaManager(ctx, config, database, pluginLogger)
47
- media.start()
48
-
49
- const recorder = new Recorder(ctx, config, database, media, contactCache, pluginLogger)
50
- recorder.start()
22
+
23
+ declare module 'koishi' {
24
+ interface Context {
25
+ console: Console
26
+ }
27
+ }
28
+
29
+ export const usage = `
30
+ ---
31
+
32
+ 基于 Satori 协议的 Koishi 后台聊天室。
33
+ 需要在 Koishi 中启用 server-satori,并在插件内构建 web 应用。
34
+
35
+ ---
36
+ `
37
+
38
+ export { Config } from './config'
39
+
40
+ export async function apply(ctx: Context, config: Config) {
41
+ const pluginLogger = createPluginLogger(ctx.logger('chat-patch'), config)
42
+
43
+ const database = new ChatDatabase(ctx, config, pluginLogger)
44
+ await database.initialize()
45
+ const contactCache = new ContactCacheService(ctx, database, pluginLogger)
46
+
47
+ const media = new MediaManager(ctx, config, database, pluginLogger)
48
+ media.start()
49
+
50
+ const recorder = new Recorder(config, database, media, contactCache, pluginLogger)
51
+ const gateway = new SatoriGateway(ctx, config, database, recorder, pluginLogger, (payload) => {
52
+ void ctx.console.broadcast('chat-patch/event', payload).catch((error) => {
53
+ pluginLogger.warn('推送前端事件失败:', error)
54
+ })
55
+ })
51
56
 
52
57
  const selfMessages = new SelfMessageRecorder(ctx, config, database, media, pluginLogger)
53
58
  selfMessages.start()
54
59
 
55
- registerBootstrap(ctx, config, database, pluginLogger)
56
- registerWeb(ctx, config, database, contactCache, media, pluginLogger)
57
-
58
- ctx.console.addEntry({
59
- dev: path.resolve(__dirname, '../client/index.ts'),
60
- prod: path.resolve(__dirname, '../dist'),
60
+ void gateway.start().catch((error) => {
61
+ pluginLogger.warn('Satori 网关启动失败:', error)
61
62
  })
62
63
 
64
+ registerBootstrap(ctx, config, database, pluginLogger, () => gateway.getLogins())
65
+ registerWeb(ctx, config, database, contactCache, media, pluginLogger)
66
+
67
+ ctx.console.addEntry({
68
+ dev: path.resolve(__dirname, '../client/index.ts'),
69
+ prod: path.resolve(__dirname, '../dist'),
70
+ })
71
+
63
72
  ctx.on('dispose', async () => {
73
+ gateway.dispose()
64
74
  selfMessages.dispose()
65
75
  media.dispose()
66
- await database.dispose()
67
- pluginLogger.logInfo('chat-patch 已卸载')
68
- })
69
- }
76
+ await database.dispose()
77
+ pluginLogger.logInfo('chat-patch 已卸载')
78
+ })
79
+ }
package/src/recorder.ts CHANGED
@@ -1,5 +1,3 @@
1
- import { Context, Session } from 'koishi'
2
-
3
1
  import { Config } from './config'
4
2
  import { ContactCacheService } from './cache'
5
3
  import { ChatDatabase } from './database'
@@ -9,13 +7,34 @@ import { PluginLogger } from './logger'
9
7
 
10
8
  const MESSAGE_TYPES = new Set(['message', 'message-created'])
11
9
 
10
+ function getObject(value: unknown): Record<string, unknown> {
11
+ return typeof value === 'object' && value !== null
12
+ ? value as Record<string, unknown>
13
+ : {}
14
+ }
15
+
16
+ function getString(value: unknown): string {
17
+ return typeof value === 'string' ? value : ''
18
+ }
19
+
20
+ function getNumber(value: unknown): number {
21
+ const num = Number(value)
22
+ return Number.isFinite(num) ? num : 0
23
+ }
24
+
12
25
  function normalizeGroupId(value: string): string {
13
26
  return String(value)
14
27
  }
15
28
 
29
+ function isPrivateChannelType(value: unknown): boolean {
30
+ const num = Number(value)
31
+ if (Number.isFinite(num)) return num === 1
32
+ const text = String(value ?? '').toLowerCase()
33
+ return text === 'direct' || text === 'private'
34
+ }
35
+
16
36
  export class Recorder {
17
37
  constructor(
18
- private ctx: Context,
19
38
  private config: Config,
20
39
  private database: ChatDatabase,
21
40
  private media: MediaManager,
@@ -23,16 +42,19 @@ export class Recorder {
23
42
  private logger: PluginLogger,
24
43
  ) {}
25
44
 
26
- start() {
27
- this.ctx.on('internal/session', (session: Session) => {
28
- if (session.type === 'message-deleted') {
29
- void this.handleMessageDeleted(session).catch((error) => {
30
- this.logger.warn('处理消息撤回失败:', error)
31
- })
32
- return
33
- }
34
- void this.handleSession(session)
35
- })
45
+ // 后端直连 Satori 后,由 SatoriGateway 逐条送入原始事件
46
+ async handleEvent(body: Record<string, unknown>): Promise<boolean> {
47
+ const type = getString(body.type)
48
+ const login = getObject(body.login)
49
+ const platform = getString(body.platform) || getString(login.platform)
50
+ if (this.isBlocked(platform)) return false
51
+ if (type === 'message-deleted') {
52
+ await this.handleMessageDeleted(body)
53
+ return true
54
+ }
55
+ if (!MESSAGE_TYPES.has(type)) return false
56
+ await this.handleMessageCreated(body)
57
+ return true
36
58
  }
37
59
 
38
60
  private isBlocked(platform: string): boolean {
@@ -43,28 +65,32 @@ export class Recorder {
43
65
  })
44
66
  }
45
67
 
46
- private async handleSession(session: Session) {
47
- const platform = session.platform || 'unknown'
48
- if (!MESSAGE_TYPES.has(session.type)) return
49
- if (this.isBlocked(platform)) return
50
-
51
- const event = session.toJSON()
52
- const message = event.message
68
+ private async handleMessageCreated(body: Record<string, unknown>) {
69
+ const login = getObject(body.login)
70
+ const loginUser = getObject(login.user)
71
+ const platform = getString(body.platform) || getString(login.platform)
72
+ const selfId = getString(body.self_id) || getString(body.selfId) || getString(loginUser.id)
73
+ const message = getObject(body.message)
74
+ const channel = getObject(body.channel)
75
+ const guild = getObject(body.guild)
76
+ const user = getObject(body.user)
77
+ const sn = getNumber(body.sn)
78
+ const timestamp = getNumber(body.timestamp) || Date.now()
53
79
  const record: MessageRecord = {
54
- id: message?.id || `local-${event.sn}`,
55
- sequence: event.sn,
56
- type: session.type,
80
+ id: getString(message.id) || `satori-${sn}`,
81
+ sequence: sn,
82
+ type: getString(body.type),
57
83
  platform,
58
- selfId: session.selfId,
59
- channelId: session.channelId,
60
- guildId: session.guildId,
61
- userId: session.userId,
62
- timestamp: session.timestamp,
63
- timestampMs: session.timestamp,
84
+ selfId,
85
+ channelId: getString(channel.id) || undefined,
86
+ guildId: getString(guild.id) || undefined,
87
+ userId: getString(user.id) || undefined,
88
+ timestamp,
89
+ timestampMs: timestamp,
64
90
  receivedAt: Date.now(),
65
- content: session.content || message?.content,
66
- elements: message?.elements as unknown[] | undefined,
67
- raw: event,
91
+ content: getString(message.content) || getString(message.raw_message) || undefined,
92
+ elements: Array.isArray(message.elements) ? message.elements as unknown[] : undefined,
93
+ raw: body,
68
94
  }
69
95
 
70
96
  try {
@@ -72,52 +98,51 @@ export class Recorder {
72
98
  } catch (error) {
73
99
  this.logger.warn('写入历史消息失败:', error)
74
100
  }
75
- void this.cacheMessageContacts(event).catch((error) => {
101
+ void this.cacheMessageContacts(body).catch((error) => {
76
102
  this.logger.warn('缓存消息联系人失败:', error)
77
103
  })
78
- void this.cacheMessageMedia(event).catch((error) => {
104
+ void this.cacheMessageMedia(message, getString(channel.id)).catch((error) => {
79
105
  this.logger.warn('异步缓存消息媒体失败:', error)
80
106
  })
81
107
  }
82
108
 
83
- private async handleMessageDeleted(session: Session) {
84
- const platform = session.platform || 'unknown'
85
- if (this.isBlocked(platform)) return
86
- const event = session.toJSON()
87
- const rawMessage = typeof event.message === 'object' && event.message !== null
88
- ? event.message as Record<string, unknown>
89
- : {}
90
- const messageId = session.messageId || String(rawMessage.id ?? '')
91
- const channelId = String(
92
- event.channel?.id
93
- || event.guild?.id
94
- || session.channelId
95
- || '',
96
- )
109
+ private async handleMessageDeleted(body: Record<string, unknown>) {
110
+ const login = getObject(body.login)
111
+ const loginUser = getObject(login.user)
112
+ const platform = getString(body.platform) || getString(login.platform)
113
+ const selfId = getString(body.self_id) || getString(body.selfId) || getString(loginUser.id)
114
+ const message = getObject(body.message)
115
+ const channel = getObject(body.channel)
116
+ const guild = getObject(body.guild)
117
+ const messageId = getString(message.id)
118
+ const channelId = getString(channel.id) || getString(guild.id)
97
119
  if (!messageId || !channelId) return
98
120
  const patch = { revoked: true, revokedAt: Date.now() }
99
- await this.database.updateMessageRevoked(platform, session.selfId, channelId, messageId, patch)
100
- await this.database.updateSelfMessageByMessageId(platform, session.selfId, channelId, messageId, patch)
121
+ await this.database.updateMessageRevoked(platform, selfId, channelId, messageId, patch)
122
+ await this.database.updateSelfMessageByMessageId(platform, selfId, channelId, messageId, patch)
101
123
  }
102
124
 
103
- private async cacheMessageContacts(event: ReturnType<Session['toJSON']>) {
104
- const platform = event.platform || ''
105
- const selfId = event.selfId || ''
106
- const user = event.user
107
- const guild = event.guild
108
- const channel = event.channel
109
- const member = event.member
110
- const userId = user?.id || ''
111
- const guildId = guild?.id || ''
112
- const channelId = channel?.id || ''
113
- const channelType = String(channel?.type ?? '')
114
- const isPrivateChannel = channelType === '1'
115
- || ['direct', 'private'].includes(channelType.toLowerCase())
125
+ private async cacheMessageContacts(body: Record<string, unknown>) {
126
+ const login = getObject(body.login)
127
+ const loginUser = getObject(login.user)
128
+ const platform = getString(body.platform) || getString(login.platform)
129
+ const selfId = getString(body.self_id) || getString(body.selfId) || getString(loginUser.id)
130
+ const user = getObject(body.user)
131
+ const guild = getObject(body.guild)
132
+ const channel = getObject(body.channel)
133
+ const member = getObject(body.member)
134
+ const userId = getString(user.id)
135
+ const guildId = getString(guild.id)
136
+ const channelId = getString(channel.id)
137
+ const isPrivateChannel = isPrivateChannelType(channel.type)
116
138
  const groupId = guildId || (isPrivateChannel ? '' : normalizeGroupId(channelId)) || ''
117
- const userName = user?.name || user?.nick || member?.nick || member?.name || ''
118
- const userAvatar = user?.avatar || member?.avatar || ''
119
- const groupName = guild?.name || channel?.name || ''
120
- const groupAvatar = guild?.avatar || ''
139
+ const userName = getString(user.name)
140
+ || getString(user.nick)
141
+ || getString(member.nick)
142
+ || getString(member.name)
143
+ const userAvatar = getString(user.avatar) || getString(member.avatar)
144
+ const groupName = getString(guild.name) || getString(channel.name)
145
+ const groupAvatar = getString(guild.avatar)
121
146
 
122
147
  if (groupId) {
123
148
  const groupItem = await this.contactCache.getGroup(
@@ -128,7 +153,7 @@ export class Recorder {
128
153
  channelId || groupId,
129
154
  groupName,
130
155
  groupAvatar,
131
- channel?.type,
156
+ channel.type,
132
157
  )
133
158
  if (userId) {
134
159
  const userItem = await this.contactCache.getUser(
@@ -152,9 +177,9 @@ export class Recorder {
152
177
  id: userId,
153
178
  user_id: userId,
154
179
  nickname: userName || userItem?.name,
155
- card: member?.nick || member?.name || '',
180
+ card: member.nick || member.name || '',
156
181
  avatar: userItem?.avatar || userAvatar || undefined,
157
- role: member?.title || '',
182
+ role: member.title || '',
158
183
  },
159
184
  },
160
185
  )
@@ -173,12 +198,7 @@ export class Recorder {
173
198
  }
174
199
  }
175
200
 
176
- private async cacheMessageMedia(event: ReturnType<Session['toJSON']>) {
177
- const channelId = String(
178
- event.channel?.id
179
- || event.guild?.id
180
- || '',
181
- )
182
- await this.media.cacheMessageMedia(event.message, channelId)
201
+ private async cacheMessageMedia(message: Record<string, unknown>, channelId: string) {
202
+ await this.media.cacheMessageMedia(message, channelId)
183
203
  }
184
204
  }
package/src/satori.ts ADDED
@@ -0,0 +1,19 @@
1
+ import { Context } from 'koishi'
2
+ import {} from '@koishijs/plugin-server'
3
+ import {} from '@satorijs/plugin-server'
4
+
5
+ export function resolveSatoriEndpoint(ctx: Context): string {
6
+ const url = ctx.satori?.server?.url ?? '/satori'
7
+ const clean = url.startsWith('undefined') ? url.slice(9) : url
8
+ if (/^https?:\/\//i.test(clean)) return clean
9
+ const base = ctx.server?.selfUrl ?? ctx.server?.config?.selfUrl ?? ''
10
+ return `${base}${clean.startsWith('/') ? clean : `/${clean}`}`
11
+ }
12
+
13
+ export function toSatoriEventUrl(endpoint: string): string {
14
+ const url = new URL(endpoint)
15
+ url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
16
+ url.pathname = `${url.pathname.replace(/\/+$/, '')}/v1/events`
17
+ url.search = ''
18
+ return url.href
19
+ }