@hedeqiang/dsh-nl2sql 0.10.0 → 0.11.1

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@hedeqiang/dsh-nl2sql",
3
- "version": "0.10.0",
4
- "description": "Data Q&A (NL2SQL) for DeepSeek Harness: ask MySQL / PostgreSQL / Elasticsearch / CSV in plain language, charts, dashboards, alerts, Lark push and bot, access control.",
3
+ "version": "0.11.1",
4
+ "description": "Data Q&A (NL2SQL) for DeepSeek Harness: ask MySQL / PostgreSQL / Elasticsearch / CSV in plain language, charts, dashboards, alerts, IM push and bots (Lark/Feishu, Telegram, WeCom, DingTalk), access control.",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": "./index.js",
@@ -12,6 +12,7 @@
12
12
  "files": [
13
13
  "index.js",
14
14
  "service/*.js",
15
+ "channels/*.js",
15
16
  "drivers.js",
16
17
  "policy.js",
17
18
  "filters.js",
@@ -30,7 +31,8 @@
30
31
  "README.md",
31
32
  "README.en.md",
32
33
  "LICENSE",
33
- "CHANGELOG.md"
34
+ "CHANGELOG.md",
35
+ "docs/CHANNELS.md"
34
36
  ],
35
37
  "dependencies": {
36
38
  "@larksuiteoapi/node-sdk": "1.76.0",
@@ -67,17 +69,20 @@
67
69
  "license": "MIT",
68
70
  "author": "hedeqiang <laravel_code@163.com>",
69
71
  "keywords": [
72
+ "bi",
70
73
  "deepseek-harness",
74
+ "dingtalk",
71
75
  "dsh",
72
76
  "dsh-bundle",
73
- "nl2sql",
74
- "text-to-sql",
75
- "bi",
76
- "mysql",
77
- "postgresql",
78
77
  "elasticsearch",
78
+ "feishu",
79
79
  "lark",
80
- "feishu"
80
+ "mysql",
81
+ "nl2sql",
82
+ "postgresql",
83
+ "telegram",
84
+ "text-to-sql",
85
+ "wecom"
81
86
  ],
82
87
  "engines": {
83
88
  "node": ">=24"
package/service/access.js CHANGED
@@ -4,6 +4,58 @@
4
4
  */
5
5
  import { ROLE_RANK, UserError, newId, oneOf, requireText, upsert } from './shared.js'
6
6
 
7
+ export const PLATFORM_LABEL = { lark: '飞书', telegram: 'Telegram', wecom: '企业微信', dingtalk: '钉钉' }
8
+ const PLATFORM_RE = /^[a-z][\w-]{0,31}$/
9
+
10
+ /** Validate and dedupe an identities array: [{ platform, id, email? }]. */
11
+ export function normalizeIdentities(list) {
12
+ if (!Array.isArray(list)) throw new UserError('nl2sql/invalid', 'identities 必须是数组')
13
+ const out = []
14
+ for (const raw of list.slice(0, 20)) {
15
+ if (!raw || typeof raw !== 'object') continue
16
+ const platform = String(raw.platform ?? '').trim().toLowerCase()
17
+ const id = String(raw.id ?? '').trim().slice(0, 200)
18
+ const email = String(raw.email ?? '').trim().slice(0, 200)
19
+ if (!id && !email) continue
20
+ if (!PLATFORM_RE.test(platform)) throw new UserError('nl2sql/invalid', `无效的平台:${raw.platform ?? ''}`)
21
+ if (!id) throw new UserError('nl2sql/invalid', `请填写 ${PLATFORM_LABEL[platform] ?? platform} 账号 ID`)
22
+ if (out.some(i => i.platform === platform && i.id === id)) continue
23
+ out.push({ platform, id, ...(email ? { email } : {}) })
24
+ }
25
+ return out
26
+ }
27
+
28
+ /** Keep the legacy larkOpenId / larkChatId fields mirroring the first Lark identity. */
29
+ function mirrorLegacyIds(u) {
30
+ const lark = u.identities.find(i => i.platform === 'lark')?.id
31
+ delete u.larkOpenId
32
+ delete u.larkChatId
33
+ if (lark) { if (u.kind === 'chat') u.larkChatId = lark; else u.larkOpenId = lark }
34
+ return u
35
+ }
36
+
37
+ /** Legacy member fields → identities, in place (idempotent). */
38
+ export function normalizeUserRecord(u) {
39
+ if (!u || typeof u !== 'object') return u
40
+ if (!Array.isArray(u.identities)) {
41
+ u.identities = []
42
+ const legacy = u.kind === 'chat' ? (u.larkChatId ?? u.larkOpenId) : (u.larkOpenId ?? u.larkChatId)
43
+ if (legacy) u.identities.push({ platform: 'lark', id: String(legacy) })
44
+ }
45
+ return mirrorLegacyIds(u)
46
+ }
47
+
48
+ /** Another member already owning one of u's identities (same platform + id) or its email. */
49
+ export function findIdentityClash(users, u) {
50
+ const mail = u.email?.toLowerCase()
51
+ return users.find(x => {
52
+ if (x.id === u.id) return false
53
+ const ids = normalizeUserRecord({ ...x, identities: x.identities ? [...x.identities] : undefined }).identities
54
+ if (u.identities.some(a => ids.some(b => a.platform === b.platform && a.id === b.id))) return true
55
+ return Boolean(mail && x.email && x.email.toLowerCase() === mail)
56
+ })
57
+ }
58
+
7
59
  export const methods = {
8
60
  // ------------------------------------------------------------ access
9
61
 
@@ -45,26 +97,38 @@ export const methods = {
45
97
  },
46
98
 
47
99
  /**
48
- * Members: people (Lark open_id / email) and Lark chats, each with a role
49
- * (admin | analyst | viewer) and a data source scope. The local owner is
50
- * implicit. Lark messages from unknown people fall back to
51
- * settings.larkDefaultRole (none = refuse).
100
+ * Members: people and group chats on IM platforms, each with a role
101
+ * (admin | analyst | viewer), a data source scope and platform-scoped
102
+ * identities [{ platform, id, email? }] (see docs/CHANNELS.md). The local
103
+ * owner is implicit. Bot messages from unknown people fall back to
104
+ * settings.botDefaultRole (none = refuse).
52
105
  */
53
106
  listUsers() {
54
- return (this.state.users ?? []).map(u => ({ ...u, datasourceNames: (u.datasourceIds ?? []).map(id => this.findDatasource(id)?.name ?? id) }))
107
+ return (this.state.users ?? []).map(u => ({ ...normalizeUserRecord(u), datasourceNames: (u.datasourceIds ?? []).map(id => this.findDatasource(id)?.name ?? id) }))
55
108
  },
56
109
 
57
110
  async saveUser(input) {
58
111
  const existing = input.id ? (this.state.users ?? []).find(u => u.id === input.id) : undefined
59
- const u = existing ? { ...existing } : { id: newId('u'), createdAt: Date.now() }
112
+ const u = existing ? normalizeUserRecord({ ...existing, identities: structuredClone(existing.identities ?? []) }) : { id: newId('u'), createdAt: Date.now(), identities: [] }
60
113
  u.kind = oneOf(input.kind ?? u.kind ?? 'person', ['person', 'chat'], 'kind')
61
114
  u.name = requireText(input.name ?? u.name, 'name', 60)
62
115
  u.role = oneOf(input.role ?? u.role ?? 'analyst', ['admin', 'analyst', 'viewer'], 'role')
63
- u.larkOpenId = String(input.larkOpenId ?? u.larkOpenId ?? '').trim().slice(0, 100) || undefined
64
116
  u.email = String(input.email ?? u.email ?? '').trim().slice(0, 200) || undefined
65
- u.larkChatId = String(input.larkChatId ?? u.larkChatId ?? '').trim().slice(0, 100) || undefined
66
- if (u.kind === 'person' && !u.larkOpenId && !u.email) throw new UserError('nl2sql/invalid', '成员需要填写飞书 open_id 或邮箱,用于识别身份')
67
- if (u.kind === 'chat' && !u.larkChatId) throw new UserError('nl2sql/invalid', '群成员需要填写飞书 chat_id')
117
+ if (input.identities !== undefined) {
118
+ u.identities = normalizeIdentities(input.identities)
119
+ } else {
120
+ // Legacy single Lark ids: replace the Lark identity (an empty value removes it).
121
+ const legacy = u.kind === 'chat' ? input.larkChatId : input.larkOpenId
122
+ const other = u.kind === 'chat' ? input.larkOpenId : input.larkChatId
123
+ if (legacy !== undefined || other !== undefined) {
124
+ const value = String(legacy ?? '').trim().slice(0, 100)
125
+ u.identities = u.identities.filter(i => i.platform !== 'lark')
126
+ if (value) u.identities.unshift({ platform: 'lark', id: value })
127
+ }
128
+ }
129
+ mirrorLegacyIds(u)
130
+ if (u.kind === 'person' && !u.identities.length && !u.email) throw new UserError('nl2sql/invalid', '成员需要填写至少一个 IM 账号(如飞书 open_id、Telegram 用户 ID)或邮箱,用于识别身份')
131
+ if (u.kind === 'chat' && !u.identities.length) throw new UserError('nl2sql/invalid', '群成员需要填写群 ID(如飞书 chat_id、Telegram chat id)')
68
132
  const allDs = input.allDatasources ?? (input.datasourceIds === undefined ? u.datasourceIds === null : false)
69
133
  u.datasourceIds = allDs ? null : (input.datasourceIds ?? u.datasourceIds ?? []).filter(id => this.findDatasource(id))
70
134
  u.defaultDatasourceId = input.defaultDatasourceId && this.findDatasource(input.defaultDatasourceId) ? input.defaultDatasourceId : (u.datasourceIds?.[0] ?? null)
@@ -78,7 +142,7 @@ export const methods = {
78
142
  })).filter(r => r.values.length).slice(0, 20)
79
143
  if (u.rowRules.some(r => !/^[A-Za-z_\u4e00-\u9fa5][\w$\u4e00-\u9fa5]*$/.test(r.column))) throw new UserError('nl2sql/invalid', '行级规则的字段名只能包含字母、数字、下划线或中文')
80
144
  }
81
- const dup = (this.state.users ?? []).find(x => x.id !== u.id && ((u.larkOpenId && x.larkOpenId === u.larkOpenId) || (u.email && x.email === u.email) || (u.larkChatId && x.larkChatId === u.larkChatId)))
145
+ const dup = findIdentityClash(this.state.users ?? [], u)
82
146
  if (dup) throw new UserError('nl2sql/duplicate', `该身份已属于成员「${dup.name}」`)
83
147
  u.updatedAt = Date.now()
84
148
  this.state.users = upsert(this.state.users ?? [], u)
@@ -95,34 +159,64 @@ export const methods = {
95
159
  return { deleted: id }
96
160
  },
97
161
 
162
+ /** Migrate legacy member fields to identities (idempotent). Returns true when something changed. */
163
+ migrateUsers() {
164
+ let changed = false
165
+ for (const u of this.state.users ?? []) {
166
+ const before = JSON.stringify(u)
167
+ normalizeUserRecord(u)
168
+ if (JSON.stringify(u) !== before) changed = true
169
+ }
170
+ return changed
171
+ },
172
+
173
+ /** Default role / data sources for unknown bot users (new keys, falling back to the legacy lark* keys). */
174
+ botDefaults() {
175
+ const s = this.state.settings
176
+ return {
177
+ role: s.botDefaultRole ?? s.larkDefaultRole ?? 'none',
178
+ datasourceIds: s.botDefaultDatasourceIds !== undefined ? s.botDefaultDatasourceIds : s.larkDefaultDatasourceIds,
179
+ }
180
+ },
181
+
98
182
  /**
99
- * Resolve the effective actor for a Lark message: the person's own scope,
100
- * narrowed by the chat's scope in group chats. Returns { actor } or { refused }.
183
+ * Resolve the effective actor for a bot message or action: the person's own
184
+ * scope, narrowed by the chat's scope in group chats. People match by
185
+ * (platform, userId) or email; chats by (platform, chatId).
186
+ * Returns { actor } or { refused }.
101
187
  */
102
- resolveLarkActor({ openId, email, name, chatId, chatType }) {
103
- const users = (this.state.users ?? []).filter(u => !u.disabled)
104
- const person = users.find(u => u.kind === 'person' && ((openId && u.larkOpenId === openId) || (email && u.email && u.email.toLowerCase() === String(email).toLowerCase())))
105
- const chat = chatType === 'group' ? users.find(u => u.kind === 'chat' && u.larkChatId === chatId) : undefined
106
- const fallbackRole = this.state.settings.larkDefaultRole ?? 'none'
107
- if (!person && !chat && fallbackRole === 'none') return { refused: '你还没有开通数据问答权限,请联系管理员在「数据问答 → 成员」中添加你的飞书账号。' }
188
+ resolveActor({ platform = 'lark', userId, email, name, chatId, chatType }) {
189
+ const users = (this.state.users ?? []).filter(u => !u.disabled).map(u => normalizeUserRecord(u))
190
+ const mail = email ? String(email).toLowerCase() : ''
191
+ const person = users.find(u => u.kind === 'person' && userId && u.identities.some(i => i.platform === platform && i.id === String(userId)))
192
+ ?? (mail ? users.find(u => u.kind === 'person' && ((u.email && u.email.toLowerCase() === mail) || u.identities.some(i => i.email && i.email.toLowerCase() === mail))) : undefined)
193
+ const chat = chatType === 'group' && chatId ? users.find(u => u.kind === 'chat' && u.identities.some(i => i.platform === platform && i.id === String(chatId))) : undefined
194
+ const defaults = this.botDefaults()
195
+ const fallbackRole = defaults.role
196
+ if (!person && !chat && fallbackRole === 'none') return { refused: `你还没有开通数据问答权限,请联系管理员在「数据问答 → 成员」中添加你的${PLATFORM_LABEL[platform] ?? platform}账号。` }
108
197
  const scopes = [person?.datasourceIds, chat?.datasourceIds].filter(x => x !== undefined)
109
198
  let datasourceIds = null
110
199
  for (const sc of scopes) if (Array.isArray(sc)) datasourceIds = datasourceIds === null ? [...sc] : datasourceIds.filter(id => sc.includes(id))
111
- if (!person && !chat) datasourceIds = this.state.settings.larkDefaultDatasourceIds ?? []
200
+ if (!person && !chat) datasourceIds = defaults.datasourceIds ?? []
112
201
  const role = person?.role ?? chat?.role ?? fallbackRole
113
202
  if (Array.isArray(datasourceIds) && datasourceIds.length === 0) return { refused: '你在当前群里没有可访问的数据源。' }
114
203
  return {
115
204
  actor: {
116
- kind: 'lark', id: person?.id ?? `lark:${openId}`, name: person?.name ?? name ?? openId,
205
+ kind: platform, platform, id: person?.id ?? `${platform}:${userId}`, name: person?.name ?? name ?? userId,
117
206
  // Group chats: the narrower of the person's and the group's role.
118
207
  role: [person?.role, chat?.role, (!person && !chat) ? fallbackRole : undefined].filter(Boolean).sort((a, b) => ROLE_RANK[a] - ROLE_RANK[b])[0] ?? role,
119
- openId, chatId, chatName: chat?.name, datasourceIds,
208
+ userId, ...(platform === 'lark' ? { openId: userId } : {}), chatId, chatName: chat?.name, datasourceIds,
120
209
  rowRules: [...(person?.rowRules ?? []), ...(chat?.rowRules ?? [])],
121
210
  defaultDatasourceId: chat?.defaultDatasourceId ?? person?.defaultDatasourceId ?? (datasourceIds?.[0] ?? null),
122
211
  },
123
212
  }
124
213
  },
125
214
 
215
+ /** Back-compat alias of resolveActor for Lark. */
216
+ resolveLarkActor({ openId, email, name, chatId, chatType }) {
217
+ return this.resolveActor({ platform: 'lark', userId: openId, email, name, chatId, chatType })
218
+ },
219
+
126
220
  auditAdmin(action, detail, actor) {
127
221
  this.records.put('admin', { id: newId('ad'), at: Date.now(), action, detail: String(detail).slice(0, 300), ...(actor ? { actor: { id: actor.id, name: actor.name, kind: actor.kind } } : {}) })
128
222
  },
package/service/admin.js CHANGED
@@ -9,6 +9,9 @@ import { join } from 'node:path'
9
9
  import { DatabaseSync } from 'node:sqlite'
10
10
  import { DEFAULT_SETTINGS, RECORD_LIMITS, UserError, newId, pick } from './shared.js'
11
11
  import { widgetWidth } from './dashboards.js'
12
+ import { kindOf } from '../channels/index.js'
13
+ import { findIdentityClash, normalizeIdentities, normalizeUserRecord } from './access.js'
14
+ import { migrateChannelRecord } from './channels.js'
12
15
 
13
16
  const PKG = (() => { try { return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) } catch { return {} } })()
14
17
 
@@ -18,7 +21,7 @@ const SECTIONS = ['settings', 'datasources', 'knowledge', 'dashboards', 'alerts'
18
21
  const DS_FIELDS = ['id', 'name', 'type', 'host', 'port', 'database', 'user', 'charset', 'timezone', 'ssl', 'schemas', 'description', 'privacy', 'policy']
19
22
  const ALERT_RUNTIME = ['state', 'lastCheckedAt', 'lastValue', 'lastResultId', 'lastFiredAt', 'lastError']
20
23
  const REPORT_FIELDS = ['id', 'title', 'scope', 'question', 'instructions', 'period', 'time', 'weekdays', 'timeZone', 'target']
21
- const CHANNEL_FIELDS = ['id', 'name', 'type', 'domain', 'appId', 'receiveIdType', 'receiveId', 'description', 'isDefault', 'chartMode']
24
+ const CHANNEL_FIELDS = ['id', 'name', 'type', 'platform', 'domain', 'appId', 'receiveIdType', 'receiveId', 'description', 'isDefault', 'chartMode', 'config', 'bot']
22
25
 
23
26
  async function fileBytes(file) {
24
27
  try { return (await stat(file)).size } catch { return 0 }
@@ -199,8 +202,10 @@ export const methods = {
199
202
  const dsName = (id) => this.findDatasource(id)?.name ?? null
200
203
  const chName = (id) => this.state.channels.find(c => c.id === id)?.name ?? null
201
204
  const settings = structuredClone(this.state.settings)
202
- if (Array.isArray(settings.larkDefaultDatasourceIds)) settings.larkDefaultDatasources = settings.larkDefaultDatasourceIds.map(dsName).filter(Boolean)
203
- delete settings.larkDefaultDatasourceIds
205
+ const botDs = settings.botDefaultDatasourceIds !== undefined ? settings.botDefaultDatasourceIds : settings.larkDefaultDatasourceIds
206
+ if (Array.isArray(botDs)) settings.botDefaultDatasources = botDs.map(dsName).filter(Boolean)
207
+ if (settings.larkDefaultRole !== undefined && settings.botDefaultRole === undefined) settings.botDefaultRole = settings.larkDefaultRole
208
+ delete settings.larkDefaultDatasourceIds; delete settings.botDefaultDatasourceIds; delete settings.larkDefaultRole
204
209
  if (settings.schemaNotifyChannel) settings.schemaNotifyChannelName = chName(settings.schemaNotifyChannel)
205
210
  delete settings.schemaNotifyChannel
206
211
  return {
@@ -234,9 +239,9 @@ export const methods = {
234
239
  channelName: chName(r.channelId),
235
240
  dashboardName: this.state.dashboards.find(d => d.id === r.dashboardId)?.name ?? null,
236
241
  })),
237
- channels: this.state.channels.map(c => ({ ...pick(c, CHANNEL_FIELDS), hasSecret: Boolean(c.hasSecret), hasSign: Boolean(c.hasSign) })),
242
+ channels: this.state.channels.map(c => { migrateChannelRecord(c); return { ...structuredClone(pick(c, CHANNEL_FIELDS)), secretsSet: { ...(c.secretsSet ?? {}) }, hasSecret: Boolean(c.hasSecret ?? Object.keys(c.secretsSet ?? {}).length), hasSign: Boolean(c.hasSign) } }),
238
243
  users: (this.state.users ?? []).map(u => {
239
- const out = structuredClone(u)
244
+ const out = normalizeUserRecord(structuredClone(u))
240
245
  out.datasources = Array.isArray(u.datasourceIds) ? u.datasourceIds.map(dsName).filter(Boolean) : null
241
246
  out.defaultDatasource = dsName(u.defaultDatasourceId)
242
247
  out.rowRules = (u.rowRules ?? []).map(r => ({ ...r, datasource: r.datasourceId ? dsName(r.datasourceId) : null, datasourceId: undefined }))
@@ -337,18 +342,24 @@ export const methods = {
337
342
  if (mode === 'replace') chMap.clear()
338
343
  for (const src of doc.channels ?? []) {
339
344
  const name = String(src?.name ?? '').trim().slice(0, 64)
340
- if (!name || !['lark_webhook', 'lark_app'].includes(src.type)) { r.skipped++; warn('channels', `渠道「${name || '?'}」无效,已跳过`); continue }
345
+ const found = kindOf(src?.type)
346
+ if (!name || !found) { r.skipped++; warn('channels', `渠道「${name || '?'}」无效${src?.type && !found ? `(不支持的类型 ${src.type})` : ''},已跳过`); continue }
341
347
  const current = mode === 'replace' ? this.state.channels.find(c => c.name === name) : next.find(c => c.name === name)
342
- const fields = pick(src, CHANNEL_FIELDS.filter(k => k !== 'id' && k !== 'isDefault'))
348
+ const fields = structuredClone(pick(src, CHANNEL_FIELDS.filter(k => k !== 'id' && k !== 'isDefault')))
349
+ fields.platform = found.adapter.id
350
+ if (fields.bot) fields.bot = this.normalizeBotConfig(fields.bot, {})
351
+ if (current && current.type !== src.type) { delete current.config; delete current.secretsSet }
343
352
  if (current) {
344
- const c = { ...current, ...fields, name, updatedAt: Date.now() }
353
+ const c = migrateChannelRecord({ ...current, ...fields, name, ...(src.config ? {} : { config: undefined }), updatedAt: Date.now() })
345
354
  const i = next.findIndex(x => x.id === current.id)
346
355
  if (i >= 0) next[i] = c; else next.push(c)
347
356
  chMap.set(name, c.id)
348
357
  r.update++
349
358
  } else {
350
359
  const id = src.id && /^ch-[\w-]+$/.test(src.id) && !next.some(c => c.id === src.id) && !this.state.channels.some(c => c.id === src.id) ? src.id : newId('ch')
351
- const c = { id, ...fields, name, hasSecret: false, hasSign: false, needsSecret: true, createdAt: Date.now(), updatedAt: Date.now(), isDefault: false }
360
+ const c = migrateChannelRecord({ id, ...fields, name, hasSecret: false, hasSign: false, secretsSet: {}, needsSecret: true, createdAt: Date.now(), updatedAt: Date.now(), isDefault: false })
361
+ // Bots need their secret back first.
362
+ if (c.bot) c.bot.enabled = false
352
363
  next.push(c)
353
364
  chMap.set(name, id)
354
365
  r.add++
@@ -496,14 +507,17 @@ export const methods = {
496
507
  if (!name || !['person', 'chat'].includes(src.kind ?? 'person') || !['admin', 'analyst', 'viewer'].includes(src.role ?? 'analyst')) { r.skipped++; continue }
497
508
  const ids = src.datasources === null || src.datasources === undefined ? null : (src.datasources ?? []).map(resolveDs).filter(Boolean)
498
509
  if (Array.isArray(src.datasources) && ids.length < src.datasources.length) warn('users', `成员「${name}」的部分数据源不存在,已忽略`)
499
- const u = {
510
+ let identities
511
+ try { identities = Array.isArray(src.identities) ? normalizeIdentities(src.identities) : undefined } catch (error) { r.skipped++; warn('users', `成员「${name}」:${error.message}`); continue }
512
+ const u = normalizeUserRecord({
500
513
  ...pick(src, ['kind', 'role', 'larkOpenId', 'email', 'larkChatId', 'disabled', 'note']),
514
+ ...(identities ? { identities } : {}),
501
515
  kind: src.kind ?? 'person', role: src.role ?? 'analyst', name,
502
516
  datasourceIds: ids, defaultDatasourceId: resolveDs(src.defaultDatasource) ?? ids?.[0] ?? null,
503
517
  rowRules: (src.rowRules ?? []).map(x => ({ datasourceId: x.datasource ? resolveDs(x.datasource) ?? null : null, column: x.column, values: x.values ?? [] })).filter(x => x.column && x.values.length),
504
518
  updatedAt: Date.now(),
505
- }
506
- const existing = next.find(x => (u.larkOpenId && x.larkOpenId === u.larkOpenId) || (u.email && x.email === u.email) || (u.larkChatId && x.larkChatId === u.larkChatId)) ?? next.find(x => x.name === name)
519
+ })
520
+ const existing = findIdentityClash(next, { ...u, id: undefined }) ?? next.find(x => x.name === name)
507
521
  if (existing) { next[next.indexOf(existing)] = { ...u, id: existing.id, createdAt: existing.createdAt }; r.update++ }
508
522
  else {
509
523
  const id = src.id && /^u-[\w-]+$/.test(src.id) && !next.some(x => x.id === src.id) ? src.id : newId('u')
@@ -518,7 +532,9 @@ export const methods = {
518
532
  if (want.has('settings') && doc.settings && typeof doc.settings === 'object') {
519
533
  const r = sec('settings')
520
534
  const input = pick(doc.settings, Object.keys(DEFAULT_SETTINGS).concat(['schemaCheckDaily']))
521
- if (Array.isArray(doc.settings.larkDefaultDatasources)) input.larkDefaultDatasourceIds = doc.settings.larkDefaultDatasources.map(resolveDs).filter(Boolean)
535
+ if (doc.settings.larkDefaultRole !== undefined && input.botDefaultRole === undefined) input.botDefaultRole = doc.settings.larkDefaultRole
536
+ const botDs = doc.settings.botDefaultDatasources ?? doc.settings.larkDefaultDatasources
537
+ if (Array.isArray(botDs)) input.botDefaultDatasourceIds = botDs.map(resolveDs).filter(Boolean)
522
538
  if (doc.settings.schemaNotifyChannelName) input.schemaNotifyChannel = resolveCh(doc.settings.schemaNotifyChannelName) ?? ''
523
539
  if (apply) {
524
540
  const before = structuredClone(this.state.settings)