@cubeai/dsh-feishu-channels 0.0.0-stage → 0.2.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 (65) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +15 -0
  3. package/README.md +56 -2
  4. package/client.js +4189 -0
  5. package/cordis.patch.yml +4 -0
  6. package/docs/coexistence.md +112 -0
  7. package/docs/dsh-feishu-channels-plan.md +1406 -0
  8. package/docs/ui-revamp-plan.md +263 -0
  9. package/docs/verification.md +120 -0
  10. package/icon.svg +17 -0
  11. package/locale/en.json +6 -0
  12. package/locale/zh.json +6 -0
  13. package/package.json +76 -4
  14. package/src/channels/connection-state.js +12 -0
  15. package/src/channels/diagnostics.js +77 -0
  16. package/src/channels/factory.js +20 -0
  17. package/src/channels/feishu-app.js +267 -0
  18. package/src/channels/feishu.js +399 -0
  19. package/src/channels/media.js +9 -0
  20. package/src/channels/meta.js +24 -0
  21. package/src/channels/qr/feishu.js +84 -0
  22. package/src/channels/qr/hub.js +229 -0
  23. package/src/channels/qr/render.js +8 -0
  24. package/src/channels/qr/shared.js +50 -0
  25. package/src/engine/abort.js +35 -0
  26. package/src/engine/access.js +23 -0
  27. package/src/engine/agent-options.js +19 -0
  28. package/src/engine/approval.js +70 -0
  29. package/src/engine/assistant-settings.js +38 -0
  30. package/src/engine/assistant-text.js +11 -0
  31. package/src/engine/atomic-file.js +45 -0
  32. package/src/engine/chat-commands.js +752 -0
  33. package/src/engine/choice-delivery.js +20 -0
  34. package/src/engine/choices.js +103 -0
  35. package/src/engine/command-locale.js +70 -0
  36. package/src/engine/command-messages.js +434 -0
  37. package/src/engine/command-permissions.js +38 -0
  38. package/src/engine/command-replies.js +59 -0
  39. package/src/engine/credentials.js +54 -0
  40. package/src/engine/deferred-delivery.js +260 -0
  41. package/src/engine/delivery-history.js +39 -0
  42. package/src/engine/file-delivery.js +215 -0
  43. package/src/engine/file-input.js +52 -0
  44. package/src/engine/file-log.js +25 -0
  45. package/src/engine/gateway.js +1337 -0
  46. package/src/engine/image-input.js +31 -0
  47. package/src/engine/json-state.js +25 -0
  48. package/src/engine/keyed-queue.js +20 -0
  49. package/src/engine/merge.js +56 -0
  50. package/src/engine/message-progress.js +274 -0
  51. package/src/engine/question.js +201 -0
  52. package/src/engine/quiet-logger.js +25 -0
  53. package/src/engine/reply-stream.js +86 -0
  54. package/src/engine/router.js +713 -0
  55. package/src/engine/secure-file.js +8 -0
  56. package/src/engine/seen-store.js +43 -0
  57. package/src/engine/session-export.js +64 -0
  58. package/src/engine/session-history.js +30 -0
  59. package/src/engine/session-id.js +62 -0
  60. package/src/engine/session-store.js +109 -0
  61. package/src/engine/session-title.js +29 -0
  62. package/src/engine/split.js +291 -0
  63. package/src/engine/workspace-path.js +9 -0
  64. package/src/index.js +62 -0
  65. package/src/manager.js +1325 -0
@@ -0,0 +1,20 @@
1
+ /** 平台已拒绝与网络结果未知必须区分,后者不能自动补发相同卡片。 */
2
+ export class ChoiceSendError extends Error {
3
+ reason;
4
+ constructor(reason) {
5
+ super(reason);
6
+ this.reason = reason;
7
+ this.name = 'ChoiceSendError';
8
+ }
9
+ }
10
+ export function choiceSendError(error) {
11
+ if (error instanceof ChoiceSendError)
12
+ return error;
13
+ const value = error;
14
+ const status = value?.status ?? value?.response?.status;
15
+ if (status === 401 || status === 403)
16
+ return new ChoiceSendError('permission-denied');
17
+ if (value?.rejected || (typeof status === 'number' && status >= 400 && status < 500 && status !== 408))
18
+ return new ChoiceSendError('rejected');
19
+ return new ChoiceSendError('delivery-unknown');
20
+ }
@@ -0,0 +1,103 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { replyText } from './command-locale.js';
3
+ import { ChoiceSendError } from './choice-delivery.js';
4
+ /** 所有按钮只携带随机索引;服务端保留动作,并绑定账号、聊天、操作者和会话。 */
5
+ export class ChoiceStore {
6
+ log;
7
+ entries = new Map();
8
+ scopes = new Map();
9
+ constructor(log = () => { }) {
10
+ this.log = log;
11
+ }
12
+ close(entry, text) {
13
+ if (entry.closedText)
14
+ return;
15
+ entry.closedText = text;
16
+ if (entry.receipt)
17
+ void Promise.resolve().then(() => entry.receipt.close(text)).catch(() => this.log('[choices] 原卡片更新失败;操作不会重试,请查看后续回复'));
18
+ }
19
+ retire(token, text) {
20
+ const entry = this.entries.get(token);
21
+ if (!entry)
22
+ return;
23
+ this.entries.delete(token);
24
+ if (this.scopes.get(entry.key) === token)
25
+ this.scopes.delete(entry.key);
26
+ this.close(entry, text ?? entry.expiredText);
27
+ }
28
+ key(channel, msg) { return JSON.stringify([channel, msg.kind ?? 'dm', msg.chatId, msg.userId ?? '']); }
29
+ clear(channel) {
30
+ for (const [token, entry] of this.entries)
31
+ if (!channel || JSON.parse(entry.key)[0] === channel)
32
+ this.retire(token);
33
+ }
34
+ async show(channel, msg, text, choices, session, valid = () => true, hint = replyText('点击选项或回复序号;15 分钟内有效,普通文字退出菜单。'), allowNumber = true) {
35
+ const key = this.key(channel.id, msg);
36
+ const previous = this.scopes.get(key);
37
+ if (previous)
38
+ this.retire(previous);
39
+ for (const [token, entry] of this.entries)
40
+ if (entry.expires <= Date.now())
41
+ this.retire(token);
42
+ if (this.entries.size >= 512)
43
+ this.retire(this.entries.keys().next().value);
44
+ const token = randomUUID();
45
+ // 回调与停用可能发生在语言作用域外,收口文案沿用发送时的卡片语言。
46
+ const entry = { key, session, expires: Date.now() + 15 * 60_000, choices, valid, allowNumber,
47
+ expiredText: replyText('此卡片已失效,请打开新的 /menu。'),
48
+ selectedTexts: choices.map(choice => replyText('已选择:{0}。此卡片已结束,请查看后续操作结果。', choice.label)),
49
+ };
50
+ this.entries.set(token, entry);
51
+ this.scopes.set(key, token);
52
+ const textHint = allowNumber ? replyText('回复序号或发送对应命令;15 分钟内有效,普通文字退出菜单。') : replyText('发送对应命令即可操作,普通文字继续聊天。');
53
+ // 只省略调用方已写入正文的选项;新增的多选提交项仍沿用原按钮编号。
54
+ const textChoices = choices.map((choice, index) => ({ choice, index })).filter(({ choice }) => !choice.textIncluded);
55
+ const body = [text, textChoices.map(({ choice, index }) => `${allowNumber ? `${index + 1}. ` : ''}${choice.label}${choice.value.startsWith('/') ? ` — ${choice.displayValue ?? choice.value}` : ''}`).join('\n'), textHint].filter(Boolean).join('\n\n');
56
+ const cardBody = allowNumber ? [text, textChoices.map(({ choice, index }) => `${index + 1}. ${choice.label}`).join('\n'), hint].filter(Boolean).join('\n\n') : text + '\n\n' + replyText('点击下方按钮选择操作;15 分钟内有效,普通文字继续聊天。');
57
+ if (channel.sendChoices && (!channel.choiceLimits || (choices.length <= channel.choiceLimits.maxButtons && cardBody.length <= channel.choiceLimits.maxTextLength))) {
58
+ try {
59
+ const receipt = await channel.sendChoices(msg, cardBody, choices.map((choice, i) => ({ label: choice.label, token: `${token}:${i}` })), { numberedChoicesIncluded: allowNumber });
60
+ entry.receipt = receipt && typeof receipt.close === 'function' ? receipt : undefined;
61
+ // 点击可能早于发送回执到达;补齐原卡片的失效状态。
62
+ if (entry.closedText && entry.receipt)
63
+ void Promise.resolve().then(() => entry.receipt.close(entry.closedText)).catch(() => this.log('[choices] 原卡片更新失败;操作不会重试,请查看后续回复'));
64
+ return '';
65
+ }
66
+ catch (error) {
67
+ const reason = error instanceof ChoiceSendError ? error.reason : 'send-failed';
68
+ this.log(`[choices] channel=${channel.id} reason=${reason} buttons=${choices.length} chars=${cardBody.length}`);
69
+ if (reason === 'delivery-unknown')
70
+ return replyText('卡片发送状态暂时无法确认。若已收到,请直接使用;未收到可发送 /menu 重试。');
71
+ }
72
+ }
73
+ else {
74
+ this.log(`[choices] channel=${channel.id} reason=${!channel.sendChoices ? 'native-unavailable' : choices.length > channel.choiceLimits.maxButtons ? 'button-limit' : 'text-limit'} buttons=${choices.length} chars=${cardBody.length}`);
75
+ }
76
+ return body;
77
+ }
78
+ resolve(channel, msg, session, allowNumber = true) {
79
+ const key = this.key(channel, msg);
80
+ const explicit = msg.actionToken;
81
+ const token = explicit?.split(':')[0] ?? this.scopes.get(key);
82
+ if (!token)
83
+ return explicit ? '' : undefined;
84
+ const entry = this.entries.get(token);
85
+ const numeric = allowNumber && entry?.allowNumber && /^\d+$/.test(msg.text.trim());
86
+ if (!explicit && !numeric) {
87
+ if (!msg.text.startsWith('/'))
88
+ this.retire(token);
89
+ return undefined;
90
+ }
91
+ if (!entry || entry.key !== key || entry.session !== session)
92
+ return '';
93
+ if (entry.expires <= Date.now() || !entry.valid()) {
94
+ this.retire(token);
95
+ return '';
96
+ }
97
+ const index = explicit ? Number(explicit.split(':')[1]) : Number(msg.text.trim()) - 1;
98
+ if (!Number.isSafeInteger(index) || index < 0 || !entry.choices[index])
99
+ return '';
100
+ this.retire(token, entry.selectedTexts[index]);
101
+ return entry.choices[index].value;
102
+ }
103
+ }
@@ -0,0 +1,70 @@
1
+ /** 与网页共用宿主保存的语言偏好;IM 无浏览器语言时兼容默认中文。 */
2
+ import { AsyncLocalStorage } from 'node:async_hooks';
3
+ import { commandEnglish } from './command-messages.js';
4
+ const localeScope = new AsyncLocalStorage();
5
+ function isLocaleDocument(row) {
6
+ const ns = String(row?.ns ?? row?.id ?? row?.namespace ?? '');
7
+ return ns === 'locale' || /(^|[./-])locale$/i.test(ns);
8
+ }
9
+ function readHostPreference(host) {
10
+ const settings = host.get('settings');
11
+ if (typeof settings?.get === 'function') {
12
+ const preference = settings.get('locale')?.preference;
13
+ if (preference !== undefined)
14
+ return preference;
15
+ }
16
+ if (typeof settings?.describe === 'function') {
17
+ for (const row of settings.describe() ?? []) {
18
+ if (!isLocaleDocument(row))
19
+ continue;
20
+ const value = row?.value?.preference ?? row?.value?.locale;
21
+ if (typeof value === 'string')
22
+ return value;
23
+ }
24
+ }
25
+ const locale = host.get('locale');
26
+ return locale?.preference ?? locale?.current;
27
+ }
28
+ export function withReplyLocale(host, run) {
29
+ let locale = 'zh';
30
+ try {
31
+ const preference = readHostPreference(host);
32
+ if (typeof preference === 'string' && /^en(?:-|$)/i.test(preference))
33
+ locale = 'en';
34
+ }
35
+ catch { /* 语言服务不可用不能阻止命令执行,保留既有中文默认。 */ }
36
+ return localeScope.run(locale, run);
37
+ }
38
+ export function replyText(key, ...values) {
39
+ const template = localeScope.getStore() === 'en' ? commandEnglish[key] : key;
40
+ // 一次替换,用户内容中的占位符、命令和路径不再次解析。
41
+ return template.replace(/\{(\d+)\}/g, (token, index) => Number(index) < values.length ? String(values[Number(index)]) : token);
42
+ }
43
+ /** 按宿主已保存的全局语言取一条用户可见提示。 */
44
+ export function notice(host, key, ...values) {
45
+ return withReplyLocale(host, () => replyText(key, ...values));
46
+ }
47
+ /** 渠道没有宿主时保持中文,避免测试夹具改变既有文案。 */
48
+ export function channelNotice(host, key, ...values) {
49
+ return host ? notice(host, key, ...values) : replyText(key, ...values);
50
+ }
51
+ function isNoticeKey(value) {
52
+ return Object.prototype.hasOwnProperty.call(commandEnglish, value);
53
+ }
54
+ /** 渠道失败分类是用户可见文案;动态 HTTP 状态单独套模板,未知分类保持原样以免丢信息。 */
55
+ export function localizeReason(host, reason) {
56
+ const http = /^下载服务器返回 HTTP (\d+)$/.exec(reason);
57
+ if (http)
58
+ return channelNotice(host, '下载服务器返回 HTTP {0}', http[1]);
59
+ const unavailable = /^下载服务器暂时不可用(HTTP (\d+))$/.exec(reason);
60
+ if (unavailable)
61
+ return channelNotice(host, '下载服务器暂时不可用(HTTP {0})', unavailable[1]);
62
+ return isNoticeKey(reason) ? channelNotice(host, reason) : reason;
63
+ }
64
+ /** 渠道 status() 仍返回中文,供设置页映射;机器人回复时再按已保存语言翻译。未知状态保持原文。 */
65
+ export function localizeStatus(host, status) {
66
+ const disconnected = /^已断开(code (\d+))$/.exec(status);
67
+ if (disconnected)
68
+ return channelNotice(host, '已断开(code {0})', disconnected[1]);
69
+ return isNoticeKey(status) ? channelNotice(host, status) : status;
70
+ }