@xmanrui/dsh-im 0.1.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 (133) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +143 -0
  3. package/THIRD_PARTY_NOTICES.md +15 -0
  4. package/bin/dsh-im.mjs +99 -0
  5. package/cordis.patch.yml +3 -0
  6. package/lib/client.js +6211 -0
  7. package/lib/index.js +13339 -0
  8. package/package.json +73 -0
  9. package/plugin-src/client/build.mjs +38 -0
  10. package/plugin-src/client/channel-logos.js +107 -0
  11. package/plugin-src/client/channels/dingtalk/api.js +203 -0
  12. package/plugin-src/client/channels/dingtalk/index.js +709 -0
  13. package/plugin-src/client/channels/dingtalk/styles.js +140 -0
  14. package/plugin-src/client/channels/discord/api.js +11 -0
  15. package/plugin-src/client/channels/discord/index.js +25 -0
  16. package/plugin-src/client/channels/discord/styles.js +19 -0
  17. package/plugin-src/client/channels/feishu/api.js +325 -0
  18. package/plugin-src/client/channels/feishu/index.js +986 -0
  19. package/plugin-src/client/channels/feishu/styles.js +505 -0
  20. package/plugin-src/client/channels/qq/api.js +119 -0
  21. package/plugin-src/client/channels/qq/index.js +402 -0
  22. package/plugin-src/client/channels/qq/styles.js +19 -0
  23. package/plugin-src/client/channels/shared/token-api.js +88 -0
  24. package/plugin-src/client/channels/shared/token-channel.js +277 -0
  25. package/plugin-src/client/channels/telegram/api.js +11 -0
  26. package/plugin-src/client/channels/telegram/index.js +25 -0
  27. package/plugin-src/client/channels/telegram/styles.js +19 -0
  28. package/plugin-src/client/channels/wecom/api.js +119 -0
  29. package/plugin-src/client/channels/wecom/index.js +402 -0
  30. package/plugin-src/client/channels/wecom/styles.js +19 -0
  31. package/plugin-src/client/channels/weixin/api.js +156 -0
  32. package/plugin-src/client/channels/weixin/index.js +577 -0
  33. package/plugin-src/client/channels/weixin/styles.js +120 -0
  34. package/plugin-src/client/credential-binding.js +117 -0
  35. package/plugin-src/client/index.js +192 -0
  36. package/plugin-src/client/lifecycle.js +86 -0
  37. package/plugin-src/client/styles.js +192 -0
  38. package/plugin-src/host/build.mjs +24 -0
  39. package/plugin-src/host/channels/dingtalk/connection-supervisor.mjs +130 -0
  40. package/plugin-src/host/channels/dingtalk/index.mjs +39 -0
  41. package/plugin-src/host/channels/dingtalk/production.mjs +122 -0
  42. package/plugin-src/host/channels/dingtalk/rpc.mjs +237 -0
  43. package/plugin-src/host/channels/discord/index.mjs +30 -0
  44. package/plugin-src/host/channels/discord/production.mjs +17 -0
  45. package/plugin-src/host/channels/discord/rpc.mjs +21 -0
  46. package/plugin-src/host/channels/feishu/connection-supervisor.mjs +167 -0
  47. package/plugin-src/host/channels/feishu/controller.mjs +172 -0
  48. package/plugin-src/host/channels/feishu/credential-store.mjs +84 -0
  49. package/plugin-src/host/channels/feishu/index.mjs +73 -0
  50. package/plugin-src/host/channels/feishu/production.mjs +138 -0
  51. package/plugin-src/host/channels/feishu/rpc.mjs +454 -0
  52. package/plugin-src/host/channels/qq/connection-supervisor.mjs +124 -0
  53. package/plugin-src/host/channels/qq/index.mjs +30 -0
  54. package/plugin-src/host/channels/qq/production.mjs +109 -0
  55. package/plugin-src/host/channels/qq/rpc.mjs +146 -0
  56. package/plugin-src/host/channels/shared/connection-supervisor.mjs +133 -0
  57. package/plugin-src/host/channels/shared/production.mjs +104 -0
  58. package/plugin-src/host/channels/shared/rpc.mjs +119 -0
  59. package/plugin-src/host/channels/telegram/index.mjs +30 -0
  60. package/plugin-src/host/channels/telegram/production.mjs +17 -0
  61. package/plugin-src/host/channels/telegram/rpc.mjs +21 -0
  62. package/plugin-src/host/channels/wecom/connection-supervisor.mjs +124 -0
  63. package/plugin-src/host/channels/wecom/index.mjs +36 -0
  64. package/plugin-src/host/channels/wecom/production.mjs +113 -0
  65. package/plugin-src/host/channels/wecom/rpc.mjs +151 -0
  66. package/plugin-src/host/channels/weixin/connection-supervisor.mjs +127 -0
  67. package/plugin-src/host/channels/weixin/index.mjs +39 -0
  68. package/plugin-src/host/channels/weixin/production.mjs +119 -0
  69. package/plugin-src/host/channels/weixin/rpc.mjs +178 -0
  70. package/plugin-src/host/index.mjs +44 -0
  71. package/plugin-src/host/rpc-authority.mjs +11 -0
  72. package/scripts/verify-package.mjs +97 -0
  73. package/src/channels/dingtalk/config-store.mjs +282 -0
  74. package/src/channels/dingtalk/device-auth.mjs +237 -0
  75. package/src/channels/dingtalk/dingtalk-api.mjs +579 -0
  76. package/src/channels/dingtalk/dingtalk-bridge.mjs +281 -0
  77. package/src/channels/dingtalk/dingtalk-card-stream.mjs +233 -0
  78. package/src/channels/dingtalk/dingtalk-controller.mjs +752 -0
  79. package/src/channels/dingtalk/dingtalk-runtime.mjs +358 -0
  80. package/src/channels/dingtalk/harness-client.mjs +299 -0
  81. package/src/channels/dingtalk/state-store.mjs +212 -0
  82. package/src/channels/discord/config-store.mjs +24 -0
  83. package/src/channels/discord/discord-api.mjs +153 -0
  84. package/src/channels/discord/discord-bridge.mjs +15 -0
  85. package/src/channels/discord/discord-controller.mjs +16 -0
  86. package/src/channels/discord/discord-runtime.mjs +460 -0
  87. package/src/channels/discord/harness-client.mjs +3 -0
  88. package/src/channels/discord/state-store.mjs +3 -0
  89. package/src/channels/feishu/bridge.mjs +216 -0
  90. package/src/channels/feishu/config.mjs +71 -0
  91. package/src/channels/feishu/feishu-app.mjs +51 -0
  92. package/src/channels/feishu/feishu-channel.mjs +153 -0
  93. package/src/channels/feishu/feishu-runtime.mjs +222 -0
  94. package/src/channels/feishu/harness-client.mjs +268 -0
  95. package/src/channels/feishu/message-utils.mjs +51 -0
  96. package/src/channels/feishu/multi-bot-controller.mjs +686 -0
  97. package/src/channels/feishu/plugin-config-store.mjs +204 -0
  98. package/src/channels/feishu/plugin-controller.mjs +248 -0
  99. package/src/channels/feishu/registration-manager.mjs +345 -0
  100. package/src/channels/feishu/state-store.mjs +71 -0
  101. package/src/channels/qq/config-store.mjs +171 -0
  102. package/src/channels/qq/harness-client.mjs +3 -0
  103. package/src/channels/qq/qq-bridge.mjs +177 -0
  104. package/src/channels/qq/qq-controller.mjs +452 -0
  105. package/src/channels/qq/qq-runtime.mjs +208 -0
  106. package/src/channels/qq/qr-auth.mjs +24 -0
  107. package/src/channels/qq/state-store.mjs +96 -0
  108. package/src/channels/shared/conversation-state-store.mjs +107 -0
  109. package/src/channels/shared/editable-message-stream.mjs +81 -0
  110. package/src/channels/shared/text-harness-bridge.mjs +180 -0
  111. package/src/channels/shared/token-bot-controller.mjs +298 -0
  112. package/src/channels/shared/token-config-store.mjs +180 -0
  113. package/src/channels/telegram/config-store.mjs +24 -0
  114. package/src/channels/telegram/harness-client.mjs +3 -0
  115. package/src/channels/telegram/state-store.mjs +3 -0
  116. package/src/channels/telegram/telegram-api.mjs +120 -0
  117. package/src/channels/telegram/telegram-bridge.mjs +15 -0
  118. package/src/channels/telegram/telegram-controller.mjs +16 -0
  119. package/src/channels/telegram/telegram-runtime.mjs +278 -0
  120. package/src/channels/wecom/config-store.mjs +170 -0
  121. package/src/channels/wecom/harness-client.mjs +3 -0
  122. package/src/channels/wecom/qr-auth.mjs +95 -0
  123. package/src/channels/wecom/state-store.mjs +90 -0
  124. package/src/channels/wecom/wecom-bridge.mjs +243 -0
  125. package/src/channels/wecom/wecom-controller.mjs +474 -0
  126. package/src/channels/wecom/wecom-runtime.mjs +209 -0
  127. package/src/channels/weixin/config-store.mjs +182 -0
  128. package/src/channels/weixin/harness-client.mjs +259 -0
  129. package/src/channels/weixin/state-store.mjs +112 -0
  130. package/src/channels/weixin/weixin-api.mjs +319 -0
  131. package/src/channels/weixin/weixin-bridge.mjs +173 -0
  132. package/src/channels/weixin/weixin-controller.mjs +545 -0
  133. package/src/channels/weixin/weixin-runtime.mjs +204 -0
@@ -0,0 +1,212 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
3
+ import { dirname } from 'node:path';
4
+
5
+ const EMPTY_STATE = Object.freeze({
6
+ version: 1,
7
+ sessions: {},
8
+ seenMessageIds: [],
9
+ pendingSenders: {},
10
+ });
11
+
12
+ function nonEmptyString(value) {
13
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
14
+ }
15
+
16
+ function displayName(value) {
17
+ return (nonEmptyString(value) ?? '钉钉用户').slice(0, 100);
18
+ }
19
+
20
+ function normalizePendingSender(value, fallbackRequestId) {
21
+ if (!value || typeof value !== 'object') return null;
22
+ const requestId = nonEmptyString(value.requestId) ?? nonEmptyString(fallbackRequestId);
23
+ const staffId = nonEmptyString(value.staffId);
24
+ const requestedAt = nonEmptyString(value.requestedAt) ?? nonEmptyString(value.lastSeenAt);
25
+ const lastSeenAt = nonEmptyString(value.lastSeenAt) ?? requestedAt;
26
+ if (!requestId || !staffId || !requestedAt || !lastSeenAt) return null;
27
+ return {
28
+ requestId,
29
+ staffId,
30
+ displayName: displayName(value.displayName ?? value.nick),
31
+ requestedAt,
32
+ lastSeenAt,
33
+ };
34
+ }
35
+
36
+ function normalizeState(value) {
37
+ if (!value || typeof value !== 'object') return structuredClone(EMPTY_STATE);
38
+ const sessions = {};
39
+ if (value.sessions && typeof value.sessions === 'object' && !Array.isArray(value.sessions)) {
40
+ for (const [key, sessionId] of Object.entries(value.sessions)) {
41
+ const normalizedKey = nonEmptyString(key);
42
+ const normalizedSession = nonEmptyString(sessionId);
43
+ if (normalizedKey && normalizedSession) sessions[normalizedKey] = normalizedSession;
44
+ }
45
+ }
46
+
47
+ const pendingSenders = {};
48
+ const entries = Array.isArray(value.pendingSenders)
49
+ ? value.pendingSenders.map((entry) => [entry?.requestId, entry])
50
+ : Object.entries(value.pendingSenders && typeof value.pendingSenders === 'object'
51
+ ? value.pendingSenders
52
+ : {});
53
+ for (const [key, candidate] of entries) {
54
+ const pending = normalizePendingSender(candidate, key);
55
+ if (!pending) continue;
56
+ const duplicate = Object.values(pendingSenders).find((entry) => entry.staffId === pending.staffId);
57
+ if (!duplicate || duplicate.lastSeenAt < pending.lastSeenAt) {
58
+ if (duplicate) delete pendingSenders[duplicate.requestId];
59
+ pendingSenders[pending.requestId] = pending;
60
+ }
61
+ }
62
+
63
+ return {
64
+ version: 1,
65
+ sessions,
66
+ seenMessageIds: Array.isArray(value.seenMessageIds)
67
+ ? [...new Set(value.seenMessageIds.map(nonEmptyString).filter(Boolean))].slice(-1_000)
68
+ : [],
69
+ pendingSenders,
70
+ };
71
+ }
72
+
73
+ export class DingtalkStateStore {
74
+ #path;
75
+ #state = structuredClone(EMPTY_STATE);
76
+ #writeQueue = Promise.resolve();
77
+ #idFactory;
78
+ #now;
79
+
80
+ constructor(path, { idFactory = randomUUID, now = () => new Date().toISOString() } = {}) {
81
+ if (!nonEmptyString(path)) throw new TypeError('state path is required');
82
+ if (typeof idFactory !== 'function' || typeof now !== 'function') {
83
+ throw new TypeError('idFactory and now must be functions');
84
+ }
85
+ this.#path = path;
86
+ this.#idFactory = idFactory;
87
+ this.#now = now;
88
+ }
89
+
90
+ async load() {
91
+ try {
92
+ this.#state = normalizeState(JSON.parse(await readFile(this.#path, 'utf8')));
93
+ } catch (error) {
94
+ if (error?.code !== 'ENOENT') throw error;
95
+ this.#state = structuredClone(EMPTY_STATE);
96
+ await this.#persist();
97
+ }
98
+ return this;
99
+ }
100
+
101
+ sessionFor(key) {
102
+ return this.#state.sessions[key] ?? null;
103
+ }
104
+
105
+ async setSession(key, sessionId) {
106
+ const normalizedKey = nonEmptyString(key);
107
+ const normalizedSession = nonEmptyString(sessionId);
108
+ if (!normalizedKey || !normalizedSession) throw new TypeError('key and sessionId are required');
109
+ this.#state.sessions[normalizedKey] = normalizedSession;
110
+ await this.#persist();
111
+ }
112
+
113
+ async clearSession(key) {
114
+ const normalizedKey = nonEmptyString(key);
115
+ if (!normalizedKey || !(normalizedKey in this.#state.sessions)) return;
116
+ delete this.#state.sessions[normalizedKey];
117
+ await this.#persist();
118
+ }
119
+
120
+ hasSeen(messageId) {
121
+ const id = nonEmptyString(messageId);
122
+ return Boolean(id && this.#state.seenMessageIds.includes(id));
123
+ }
124
+
125
+ async markSeen(messageId) {
126
+ const id = nonEmptyString(messageId);
127
+ if (!id) throw new TypeError('messageId is required');
128
+ if (this.hasSeen(id)) return;
129
+ this.#state.seenMessageIds.push(id);
130
+ if (this.#state.seenMessageIds.length > 1_000) {
131
+ this.#state.seenMessageIds.splice(0, this.#state.seenMessageIds.length - 1_000);
132
+ }
133
+ await this.#persist();
134
+ }
135
+
136
+ pendingSenders() {
137
+ return Object.values(this.#state.pendingSenders)
138
+ .sort((left, right) => left.requestedAt.localeCompare(right.requestedAt))
139
+ .map((entry) => structuredClone(entry));
140
+ }
141
+
142
+ pendingSender(requestId) {
143
+ const id = nonEmptyString(requestId);
144
+ const entry = id ? this.#state.pendingSenders[id] : null;
145
+ return entry ? structuredClone(entry) : null;
146
+ }
147
+
148
+ async recordPendingSender(staffIdOrEntry, name, seenAt) {
149
+ const input = staffIdOrEntry && typeof staffIdOrEntry === 'object'
150
+ ? staffIdOrEntry
151
+ : { staffId: staffIdOrEntry, displayName: name, lastSeenAt: seenAt };
152
+ const staffId = nonEmptyString(input.staffId);
153
+ if (!staffId) throw new TypeError('staffId is required');
154
+ const timestamp = nonEmptyString(input.lastSeenAt) ?? nonEmptyString(input.requestedAt) ?? this.#now();
155
+ const existing = Object.values(this.#state.pendingSenders)
156
+ .find((entry) => entry.staffId === staffId);
157
+ const entry = {
158
+ requestId: existing?.requestId ?? `ding_sender_${this.#idFactory()}`,
159
+ staffId,
160
+ displayName: displayName(input.displayName ?? input.nick ?? name),
161
+ requestedAt: existing?.requestedAt ?? timestamp,
162
+ lastSeenAt: timestamp,
163
+ };
164
+ this.#state.pendingSenders[entry.requestId] = entry;
165
+ await this.#persist();
166
+ return structuredClone(entry);
167
+ }
168
+
169
+ async removePendingSender(requestId) {
170
+ const id = nonEmptyString(requestId);
171
+ if (!id || !this.#state.pendingSenders[id]) return false;
172
+ delete this.#state.pendingSenders[id];
173
+ await this.#persist();
174
+ return true;
175
+ }
176
+
177
+ async removePendingSenderByStaffId(staffId) {
178
+ const id = nonEmptyString(staffId);
179
+ const pending = id
180
+ ? Object.values(this.#state.pendingSenders).find((entry) => entry.staffId === id)
181
+ : null;
182
+ return pending ? this.removePendingSender(pending.requestId) : false;
183
+ }
184
+
185
+ snapshot() {
186
+ return structuredClone(this.#state);
187
+ }
188
+
189
+ async remove() {
190
+ await this.#writeQueue;
191
+ try {
192
+ await unlink(this.#path);
193
+ } catch (error) {
194
+ if (error?.code !== 'ENOENT') throw error;
195
+ }
196
+ this.#state = structuredClone(EMPTY_STATE);
197
+ }
198
+
199
+ async #persist() {
200
+ const snapshot = `${JSON.stringify(this.#state, null, 2)}\n`;
201
+ const operation = this.#writeQueue.then(async () => {
202
+ await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 });
203
+ const temporary = `${this.#path}.tmp`;
204
+ await writeFile(temporary, snapshot, { encoding: 'utf8', mode: 0o600 });
205
+ await rename(temporary, this.#path);
206
+ });
207
+ this.#writeQueue = operation.then(() => undefined, () => undefined);
208
+ await operation;
209
+ }
210
+ }
211
+
212
+ export const DingTalkStateStore = DingtalkStateStore;
@@ -0,0 +1,24 @@
1
+ import {
2
+ deriveTokenBotIdentity,
3
+ maskPlatformId,
4
+ TokenBotConfigStore,
5
+ } from '../shared/token-config-store.mjs';
6
+
7
+ const IDENTITY_OPTIONS = Object.freeze({
8
+ botPrefix: 'discord',
9
+ tokenRefPrefix: 'DSH_DISCORD_BOT_TOKEN',
10
+ });
11
+
12
+ export function deriveDiscordBotIdentity(platformId) {
13
+ return deriveTokenBotIdentity(platformId, IDENTITY_OPTIONS);
14
+ }
15
+
16
+ export function maskDiscordBotId(platformId) {
17
+ return maskPlatformId(platformId, 'Discord机器人');
18
+ }
19
+
20
+ export class DiscordConfigStore extends TokenBotConfigStore {
21
+ constructor(path) {
22
+ super(path, { channel: 'Discord', ...IDENTITY_OPTIONS });
23
+ }
24
+ }
@@ -0,0 +1,153 @@
1
+ const DEFAULT_BASE_URL = 'https://discord.com/api/v10/';
2
+
3
+ function cleanString(value) {
4
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
5
+ }
6
+
7
+ function requestSignal(signal, timeoutMs) {
8
+ const timeout = AbortSignal.timeout(timeoutMs);
9
+ return signal ? AbortSignal.any([signal, timeout]) : timeout;
10
+ }
11
+
12
+ function delay(ms, signal) {
13
+ return new Promise((resolve, reject) => {
14
+ if (signal?.aborted) {
15
+ reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
16
+ return;
17
+ }
18
+ const timer = setTimeout(resolve, ms);
19
+ timer?.unref?.();
20
+ signal?.addEventListener('abort', () => {
21
+ clearTimeout(timer);
22
+ reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
23
+ }, { once: true });
24
+ });
25
+ }
26
+
27
+ function snowflake(value, name) {
28
+ const id = cleanString(value);
29
+ if (!id || !/^\d{5,30}$/.test(id)) throw new TypeError(`Invalid Discord ${name}`);
30
+ return id;
31
+ }
32
+
33
+ export function validDiscordToken(value) {
34
+ return typeof value === 'string'
35
+ && /^[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{20,}$/.test(value.trim());
36
+ }
37
+
38
+ export class DiscordApi {
39
+ #token;
40
+ #fetch;
41
+ #baseUrl;
42
+
43
+ constructor({ token, fetchImpl = fetch, baseUrl = DEFAULT_BASE_URL }) {
44
+ if (!validDiscordToken(token)) throw new TypeError('Discord Bot Token is invalid');
45
+ if (typeof fetchImpl !== 'function') throw new TypeError('DiscordApi requires fetch');
46
+ this.#token = token.trim();
47
+ this.#fetch = fetchImpl;
48
+ this.#baseUrl = new URL(baseUrl);
49
+ }
50
+
51
+ getCurrentUser(options = {}) {
52
+ return this.#request('users/@me', { ...options, method: 'GET' });
53
+ }
54
+
55
+ getGatewayBot(options = {}) {
56
+ return this.#request('gateway/bot', { ...options, method: 'GET' });
57
+ }
58
+
59
+ createMessage({ channelId, content, replyToMessageId, signal }) {
60
+ return this.#request(`channels/${snowflake(channelId, 'channel id')}/messages`, {
61
+ method: 'POST',
62
+ signal,
63
+ body: {
64
+ content,
65
+ allowed_mentions: { parse: [], replied_user: false },
66
+ ...(replyToMessageId ? {
67
+ message_reference: {
68
+ message_id: snowflake(replyToMessageId, 'message id'),
69
+ channel_id: snowflake(channelId, 'channel id'),
70
+ fail_if_not_exists: false,
71
+ },
72
+ } : {}),
73
+ },
74
+ });
75
+ }
76
+
77
+ editMessage({ channelId, messageId, content, signal }) {
78
+ return this.#request(
79
+ `channels/${snowflake(channelId, 'channel id')}/messages/${snowflake(messageId, 'message id')}`,
80
+ {
81
+ method: 'PATCH',
82
+ signal,
83
+ body: { content, allowed_mentions: { parse: [], replied_user: false } },
84
+ },
85
+ );
86
+ }
87
+
88
+ sendTyping({ channelId, signal }) {
89
+ return this.#request(`channels/${snowflake(channelId, 'channel id')}/typing`, {
90
+ method: 'POST',
91
+ signal,
92
+ expectBody: false,
93
+ });
94
+ }
95
+
96
+ async #request(path, {
97
+ method,
98
+ body,
99
+ signal,
100
+ timeoutMs = 15_000,
101
+ expectBody = true,
102
+ retry = true,
103
+ }) {
104
+ let response;
105
+ try {
106
+ response = await this.#fetch(new URL(path, this.#baseUrl), {
107
+ method,
108
+ headers: {
109
+ authorization: `Bot ${this.#token}`,
110
+ 'content-type': 'application/json',
111
+ 'user-agent': 'DeepSeek-Harness-dsh-im (https://github.com/xmanrui/dsh-im, 0.1.0)',
112
+ },
113
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
114
+ signal: requestSignal(signal, timeoutMs),
115
+ redirect: 'error',
116
+ });
117
+ } catch (error) {
118
+ if (error?.name === 'AbortError' || error?.name === 'TimeoutError') throw error;
119
+ throw new Error(`Discord ${method} transport failed`);
120
+ }
121
+
122
+ let parsed = null;
123
+ if (expectBody || response.status === 429 || !response.ok) {
124
+ try {
125
+ parsed = await response.json();
126
+ } catch {
127
+ if (expectBody) throw new Error(`Discord ${method} returned invalid JSON`);
128
+ }
129
+ }
130
+ if (response.status === 429 && retry) {
131
+ const retryAfterMs = Math.min(10_000, Math.max(50, Number(parsed?.retry_after) * 1_000 || 1_000));
132
+ await delay(retryAfterMs, signal);
133
+ return this.#request(path, { method, body, signal, timeoutMs, expectBody, retry: false });
134
+ }
135
+ if (!response.ok) {
136
+ const error = new Error(cleanString(parsed?.message) ?? `Discord API failed with HTTP ${response.status}`);
137
+ error.code = `discord-${response.status}`;
138
+ throw error;
139
+ }
140
+ return expectBody ? parsed : null;
141
+ }
142
+ }
143
+
144
+ export async function inspectDiscordToken(token, options = {}) {
145
+ const api = new DiscordApi({ token, ...options });
146
+ const bot = await api.getCurrentUser();
147
+ if (!bot?.id || bot?.bot !== true) throw new Error('Discord token does not belong to a bot');
148
+ return {
149
+ platformId: String(bot.id),
150
+ name: cleanString(bot.global_name) ?? cleanString(bot.username) ?? 'Discord机器人',
151
+ username: cleanString(bot.username),
152
+ };
153
+ }
@@ -0,0 +1,15 @@
1
+ import { TextHarnessBridge, createTextBridgeStatus } from '../shared/text-harness-bridge.mjs';
2
+
3
+ export const DISCORD_DESCRIPTOR = Object.freeze({
4
+ key: 'discord',
5
+ label: 'Discord',
6
+ connectionLabel: ' Gateway 长连接',
7
+ });
8
+
9
+ export class DiscordHarnessBridge extends TextHarnessBridge {
10
+ constructor(options) {
11
+ super({ descriptor: DISCORD_DESCRIPTOR, ...options });
12
+ }
13
+ }
14
+
15
+ export { createTextBridgeStatus as createDiscordBridgeStatus };
@@ -0,0 +1,16 @@
1
+ import { TokenBotController } from '../shared/token-bot-controller.mjs';
2
+ import { deriveDiscordBotIdentity, maskDiscordBotId } from './config-store.mjs';
3
+ import { inspectDiscordToken } from './discord-api.mjs';
4
+ import { DISCORD_DESCRIPTOR } from './discord-bridge.mjs';
5
+
6
+ export class DiscordController extends TokenBotController {
7
+ constructor(options) {
8
+ super({
9
+ ...options,
10
+ descriptor: DISCORD_DESCRIPTOR,
11
+ inspectToken: options.inspectToken ?? inspectDiscordToken,
12
+ deriveIdentity: deriveDiscordBotIdentity,
13
+ maskPlatformId: maskDiscordBotId,
14
+ });
15
+ }
16
+ }