@xmanrui/dsh-im 2.5.0 → 3.0.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 (37) hide show
  1. package/README.en.md +4 -4
  2. package/README.md +4 -4
  3. package/bin/dsh-im.mjs +1 -1
  4. package/lib/client.js +1410 -1409
  5. package/lib/index.js +206 -206
  6. package/package.json +2 -2
  7. package/plugin-src/client/channels/dingtalk/index.js +0 -17
  8. package/plugin-src/client/channels/dingtalk/styles.js +2 -2
  9. package/plugin-src/client/channels/feishu/index.js +0 -29
  10. package/plugin-src/client/channels/feishu/styles.js +2 -2
  11. package/plugin-src/client/channels/qq/index.js +0 -9
  12. package/plugin-src/client/channels/wecom/index.js +0 -9
  13. package/plugin-src/client/channels/weixin/index.js +0 -19
  14. package/plugin-src/client/channels/weixin/styles.js +2 -2
  15. package/plugin-src/client/index.js +4 -4
  16. package/scripts/verify-package.mjs +48 -11
  17. package/src/channels/dingtalk/dingtalk-api.mjs +101 -5
  18. package/src/channels/dingtalk/dingtalk-bridge.mjs +198 -22
  19. package/src/channels/discord/discord-api.mjs +35 -0
  20. package/src/channels/discord/discord-bridge.mjs +1 -0
  21. package/src/channels/discord/discord-runtime.mjs +26 -0
  22. package/src/channels/feishu/bridge.mjs +21 -28
  23. package/src/channels/feishu/message-utils.mjs +1 -1
  24. package/src/channels/shared/connection-test.mjs +1 -1
  25. package/src/channels/shared/i18n-en/feishu.mjs +6 -6
  26. package/src/channels/shared/i18n-en/shared-c.mjs +2 -2
  27. package/src/channels/shared/status-reaction.mjs +107 -0
  28. package/src/channels/shared/text-harness-bridge.mjs +58 -6
  29. package/src/channels/slack/manifest.mjs +1 -0
  30. package/src/channels/slack/slack-api.mjs +28 -0
  31. package/src/channels/slack/slack-bridge.mjs +5 -0
  32. package/src/channels/slack/slack-runtime.mjs +24 -0
  33. package/src/channels/telegram/telegram-api.mjs +9 -0
  34. package/src/channels/telegram/telegram-bridge.mjs +1 -0
  35. package/src/channels/telegram/telegram-runtime.mjs +20 -0
  36. package/src/channels/whatsapp/whatsapp-bridge.mjs +1 -0
  37. package/src/channels/whatsapp/whatsapp-runtime.mjs +36 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmanrui/dsh-im",
3
- "version": "2.5.0",
3
+ "version": "3.0.0",
4
4
  "description": "把九种 IM 机器人和公网 AI Office 接入本机 DeepSeek Harness。 Connect nine IM channels and a public AI Office to a local DeepSeek Harness.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -83,7 +83,7 @@
83
83
  "scripts": {
84
84
  "build": "node plugin-src/client/build.mjs && node plugin-src/host/build.mjs",
85
85
  "test": "node --test test/*.test.mjs test/channels/*/*.test.mjs",
86
- "check": "npm test && npm run build && node scripts/verify-package.mjs"
86
+ "check": "npm run build && npm test && node scripts/verify-package.mjs"
87
87
  },
88
88
  "engines": {
89
89
  "node": ">=22.19"
@@ -16,7 +16,6 @@ import {
16
16
  } from '../../channel-card-meta.js';
17
17
  import {
18
18
  DINGTALK_ENDPOINTS,
19
- DINGTALK_RPC_CHANNEL,
20
19
  connectionTestFeedback,
21
20
  formatRemaining,
22
21
  normalizeProvisioning,
@@ -29,9 +28,6 @@ import { installDingtalkStyles } from './styles.js';
29
28
 
30
29
  const ACTIVE_PROVISION_STATES = new Set(['pending', 'scanned', 'authorizing', 'creating', 'connecting']);
31
30
 
32
- export const name = 'dingtalk-settings';
33
- export const inject = ['slots', 'connection'];
34
-
35
31
  function DingtalkIcon({ size = 28 }) {
36
32
  return h('svg', {
37
33
  width: size,
@@ -864,16 +860,3 @@ export function DingtalkSettingsTab({ rpcCall }) {
864
860
  })
865
861
  : null)));
866
862
  }
867
-
868
- export function apply(ctx) {
869
- ctx.effect(() => installDingtalkStyles(), 'dingtalk-settings: install client styles');
870
- const rpcCall = (endpoint, payload, signal) =>
871
- ctx.connection.rpc.call(DINGTALK_RPC_CHANNEL, endpoint, payload, signal);
872
- ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
873
- name: 'settings.plugins.tab',
874
- id: 'dingtalk',
875
- order: 40,
876
- label: '钉钉',
877
- inject: () => ({ rpcCall }),
878
- }, DingtalkSettingsTab));
879
- }
@@ -1,4 +1,4 @@
1
- export const DINGTALK_STYLE_ID = 'xmanrui-dsh-dingtalk-settings';
1
+ export const DINGTALK_STYLE_ID = 'xmanrui-dsh-im-dingtalk-settings';
2
2
 
3
3
  const CSS = String.raw`
4
4
  .ddt-page {
@@ -126,7 +126,7 @@ export function installDingtalkStyles() {
126
126
  const existing = document.querySelector(`style[data-plugin-css="${DINGTALK_STYLE_ID}"]`);
127
127
  if (existing) return () => {};
128
128
  const style = document.createElement('style');
129
- style.dataset.plugin = '@xmanrui/dsh-dingtalk';
129
+ style.dataset.plugin = '@xmanrui/dsh-im';
130
130
  style.dataset.pluginCss = DINGTALK_STYLE_ID;
131
131
  style.textContent = CSS;
132
132
  document.head.appendChild(style);
@@ -6,7 +6,6 @@ import { h } from "../../i18n.js";
6
6
  import {
7
7
  FEISHU_ENDPOINTS,
8
8
  FEISHU_REGISTRATION_OPERATIONS,
9
- FEISHU_RPC_CHANNEL,
10
9
  formatRemaining,
11
10
  normalizeBotsSnapshot,
12
11
  normalizeGroupResponseMode,
@@ -28,11 +27,6 @@ import {
28
27
  ChannelListHeading,
29
28
  LastMessageErrorSummary,
30
29
  } from "../../channel-card-meta.js";
31
- import { installFeishuStyles } from "./styles.js";
32
-
33
- export const name = "feishu-settings";
34
- export const inject = ["slots", "connection"];
35
-
36
30
  const CALLBACK_REPAIR_OPERATION = FEISHU_REGISTRATION_OPERATIONS.CALLBACK_REPAIR;
37
31
  const GROUP_MESSAGE_PERMISSION_OPERATION = FEISHU_REGISTRATION_OPERATIONS.GROUP_MESSAGE_PERMISSION;
38
32
 
@@ -1540,26 +1534,3 @@ export function FeishuSettingsTab({ rpcCall }) {
1540
1534
  ),
1541
1535
  ));
1542
1536
  }
1543
-
1544
- export function apply(ctx) {
1545
- ctx.effect(
1546
- () => installFeishuStyles(),
1547
- "feishu-settings: install client styles",
1548
- );
1549
-
1550
- const rpcCall = (endpoint, payload, signal) =>
1551
- ctx.connection.rpc.call(FEISHU_RPC_CHANNEL, endpoint, payload, signal);
1552
-
1553
- ctx.slots.inject("settings.plugins.tab", () =>
1554
- ctx.slots.register(
1555
- {
1556
- name: "settings.plugins.tab",
1557
- id: "feishu",
1558
- order: 20,
1559
- label: "飞书",
1560
- inject: () => ({ rpcCall }),
1561
- },
1562
- FeishuSettingsTab,
1563
- ),
1564
- );
1565
- }
@@ -1,4 +1,4 @@
1
- export const FEISHU_STYLE_ID = "beihuixinghe-dsh-feishu-settings";
1
+ export const FEISHU_STYLE_ID = "xmanrui-dsh-im-feishu-settings";
2
2
 
3
3
  const CSS = String.raw`
4
4
  .bxf-page {
@@ -562,7 +562,7 @@ export function installFeishuStyles() {
562
562
  }
563
563
 
564
564
  const style = document.createElement("style");
565
- style.dataset.plugin = "@xmanrui/dsh-feishu";
565
+ style.dataset.plugin = "@xmanrui/dsh-im";
566
566
  style.dataset.pluginCss = FEISHU_STYLE_ID;
567
567
  style.textContent = CSS;
568
568
  document.head.appendChild(style);
@@ -18,7 +18,6 @@ import {
18
18
  import { installDingtalkStyles } from '../dingtalk/styles.js';
19
19
  import {
20
20
  QQ_ENDPOINTS,
21
- QQ_RPC_CHANNEL,
22
21
  connectionTestFeedback,
23
22
  formatRemaining,
24
23
  normalizeProvisioning,
@@ -514,11 +513,3 @@ export function QqSettingsTab({ rpcCall }) {
514
513
  ? h(EmptyView, { busy, onStart: () => void startProvisioning() }) : null,
515
514
  botList)));
516
515
  }
517
-
518
- export function apply(ctx) {
519
- ctx.effect(() => installQqStyles(), 'qq-settings: install client styles');
520
- const rpcCall = (endpoint, payload, signal) => ctx.connection.rpc.call(QQ_RPC_CHANNEL, endpoint, payload, signal);
521
- ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
522
- name: 'settings.plugins.tab', id: 'qq', order: 50, label: 'QQ', inject: () => ({ rpcCall }),
523
- }, QqSettingsTab));
524
- }
@@ -18,7 +18,6 @@ import {
18
18
  import { installDingtalkStyles } from '../dingtalk/styles.js';
19
19
  import {
20
20
  WECOM_ENDPOINTS,
21
- WECOM_RPC_CHANNEL,
22
21
  formatRemaining,
23
22
  normalizeProvisioning,
24
23
  normalizeSnapshot,
@@ -546,11 +545,3 @@ export function WecomSettingsTab({ rpcCall }) {
546
545
  ? h(EmptyView, { busy, onStart: () => void startProvisioning() }) : null,
547
546
  botList)));
548
547
  }
549
-
550
- export function apply(ctx) {
551
- ctx.effect(() => installWecomStyles(), 'wecom-settings: install client styles');
552
- const rpcCall = (endpoint, payload, signal) => ctx.connection.rpc.call(WECOM_RPC_CHANNEL, endpoint, payload, signal);
553
- ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
554
- name: 'settings.plugins.tab', id: 'wecom', order: 45, label: '企业微信', inject: () => ({ rpcCall }),
555
- }, WecomSettingsTab));
556
- }
@@ -5,7 +5,6 @@ import { QrActionIcon } from '../../credential-binding.js';
5
5
  import { h } from '../../i18n.js';
6
6
  import {
7
7
  WEIXIN_ENDPOINTS,
8
- WEIXIN_RPC_CHANNEL,
9
8
  formatRemaining,
10
9
  normalizeProvisioning,
11
10
  normalizeSnapshot,
@@ -27,11 +26,6 @@ import {
27
26
  ChannelListHeading,
28
27
  LastMessageErrorSummary,
29
28
  } from '../../channel-card-meta.js';
30
- import { installWeixinStyles } from './styles.js';
31
-
32
- export const name = 'weixin-settings';
33
- export const inject = ['slots', 'connection'];
34
-
35
29
  const Button = React.forwardRef(function Button(
36
30
  { children, kind = 'secondary', className = '', ...props },
37
31
  ref,
@@ -717,16 +711,3 @@ export function WeixinSettingsTab({ rpcCall }) {
717
711
  : null),
718
712
  ));
719
713
  }
720
-
721
- export function apply(ctx) {
722
- ctx.effect(() => installWeixinStyles(), 'weixin-settings: install client styles');
723
- const rpcCall = (endpoint, payload, signal) =>
724
- ctx.connection.rpc.call(WEIXIN_RPC_CHANNEL, endpoint, payload, signal);
725
- ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
726
- name: 'settings.plugins.tab',
727
- id: 'weixin',
728
- order: 30,
729
- label: '微信',
730
- inject: () => ({ rpcCall }),
731
- }, WeixinSettingsTab));
732
- }
@@ -1,4 +1,4 @@
1
- export const WEIXIN_STYLE_ID = 'xmanrui-dsh-weixin-settings';
1
+ export const WEIXIN_STYLE_ID = 'xmanrui-dsh-im-weixin-settings';
2
2
 
3
3
  const CSS = String.raw`
4
4
  .dxw-page {
@@ -107,7 +107,7 @@ export function installWeixinStyles() {
107
107
  const existing = document.querySelector(`style[data-plugin-css="${WEIXIN_STYLE_ID}"]`);
108
108
  if (existing) return () => {};
109
109
  const style = document.createElement('style');
110
- style.dataset.plugin = '@xmanrui/dsh-weixin';
110
+ style.dataset.plugin = '@xmanrui/dsh-im';
111
111
  style.dataset.pluginCss = WEIXIN_STYLE_ID;
112
112
  style.textContent = CSS;
113
113
  document.head.appendChild(style);
@@ -333,10 +333,10 @@ export function apply(ctx) {
333
333
  pickDirectory: () => ctx.workspaces.pickDirectory(),
334
334
  });
335
335
 
336
- ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
337
- name: 'settings.plugins.tab',
338
- id: 'im',
339
- order: 20,
336
+ ctx.slots.inject('settings.section', () => ctx.slots.register({
337
+ name: 'settings.section',
338
+ id: 'xmanrui-dsh-im',
339
+ order: 21,
340
340
  label: () => t('IM机器人'),
341
341
  locale: IM_LOCALE_NAMESPACE,
342
342
  inject: () => ({
@@ -1,8 +1,23 @@
1
- import { access, readFile, stat } from 'node:fs/promises';
1
+ import { access, readFile, readdir, stat } from 'node:fs/promises';
2
2
  import { resolve } from 'node:path';
3
3
  import { pathToFileURL } from 'node:url';
4
4
 
5
5
  const root = resolve(import.meta.dirname, '..');
6
+
7
+ async function readSourceTree(directory) {
8
+ const entries = await readdir(directory, { withFileTypes: true });
9
+ const chunks = [];
10
+ for (const entry of entries) {
11
+ const path = resolve(directory, entry.name);
12
+ if (entry.isDirectory()) {
13
+ chunks.push(await readSourceTree(path));
14
+ } else if (entry.isFile() && /\.m?js$/u.test(entry.name)) {
15
+ chunks.push(await readFile(path, 'utf8'));
16
+ }
17
+ }
18
+ return chunks.join('\n');
19
+ }
20
+
6
21
  const required = [
7
22
  'lib/index.js',
8
23
  'lib/client.js',
@@ -35,7 +50,17 @@ const required = [
35
50
  ];
36
51
  await Promise.all(required.map((path) => access(resolve(root, path))));
37
52
 
38
- const [client, host, patch, manifestText, lockText, hostSource, clientSource, executable] = await Promise.all([
53
+ const [
54
+ client,
55
+ host,
56
+ patch,
57
+ manifestText,
58
+ lockText,
59
+ hostSource,
60
+ clientEntrySource,
61
+ clientSources,
62
+ executable,
63
+ ] = await Promise.all([
39
64
  readFile(resolve(root, 'lib/client.js'), 'utf8'),
40
65
  readFile(resolve(root, 'lib/index.js'), 'utf8'),
41
66
  readFile(resolve(root, 'cordis.patch.yml'), 'utf8'),
@@ -43,6 +68,7 @@ const [client, host, patch, manifestText, lockText, hostSource, clientSource, ex
43
68
  readFile(resolve(root, 'package-lock.json'), 'utf8'),
44
69
  readFile(resolve(root, 'plugin-src/host/index.mjs'), 'utf8'),
45
70
  readFile(resolve(root, 'plugin-src/client/index.js'), 'utf8'),
71
+ readSourceTree(resolve(root, 'plugin-src/client')),
46
72
  stat(resolve(root, 'bin/dsh-im.mjs')),
47
73
  ]);
48
74
  const manifest = JSON.parse(manifestText);
@@ -82,17 +108,28 @@ if (forbiddenDshLockPaths.length > 0) {
82
108
  );
83
109
  }
84
110
 
85
- if (!client.includes('id: "@xmanrui/dsh-im"')) {
111
+ if (!/\bid\s*:\s*["']@xmanrui\/dsh-im["']/u.test(client)) {
86
112
  throw new Error('client bundle does not register the dsh-im loader id');
87
113
  }
88
- if (!client.includes('id: "im"')
89
- || !client.includes('label: () => t("IM\\u673A\\u5668\\u4EBA")')
90
- || !client.includes('locale: IM_LOCALE_NAMESPACE')
91
- || !client.includes('IM_LOCALE_NAMESPACE = "dsh-im"')) {
92
- throw new Error('client bundle does not register the localized IM settings tab');
114
+ const sourceSectionMarkers = [
115
+ /ctx\.slots\.inject\(\s*["']settings\.section["']/u,
116
+ /name\s*:\s*["']settings\.section["']/u,
117
+ /id\s*:\s*["']xmanrui-dsh-im["']/u,
118
+ /order\s*:\s*21\b/u,
119
+ /label\s*:\s*\(\)\s*=>\s*t\(\s*["']IM机器人["']\s*\)/u,
120
+ /locale\s*:\s*IM_LOCALE_NAMESPACE\b/u,
121
+ ];
122
+ const bundleSectionPattern = /name\s*:\s*["']settings\.section["']\s*,\s*id\s*:\s*["']xmanrui-dsh-im["']\s*,\s*order\s*:\s*21\s*,\s*label\s*:\s*\(\)\s*=>\s*[$A-Z_a-z][$\w]*\(\s*["']IM(?:机器人|\\u673A\\u5668\\u4EBA)["']\s*\)\s*,\s*locale\s*:\s*(?:[$A-Z_a-z][$\w]*|["']dsh-im["'])/u;
123
+ if (sourceSectionMarkers.some((pattern) => !pattern.test(clientEntrySource))
124
+ || !/IM_LOCALE_NAMESPACE\s*=\s*["']dsh-im["']/u.test(clientSources)
125
+ || !bundleSectionPattern.test(client)) {
126
+ throw new Error('client bundle does not register the localized top-level IM settings section');
127
+ }
128
+ if ((client.match(/\.slots\.inject\(\s*["']settings\.section["']/gu) ?? []).length !== 1) {
129
+ throw new Error('client bundle must register exactly one top-level settings section');
93
130
  }
94
- if ((client.match(/ctx\.slots\.inject\("settings\.plugins\.tab"/g) ?? []).length !== 1) {
95
- throw new Error('client bundle must register exactly one settings tab');
131
+ if (client.includes('settings.plugins.tab') || clientSources.includes('settings.plugins.tab')) {
132
+ throw new Error('client source or bundle still contains the legacy Plugins-tab settings entry');
96
133
  }
97
134
  if (/role:\s*["']switch|type:\s*["']checkbox/.test(client)) {
98
135
  throw new Error('client bundle contains a channel enable switch');
@@ -115,7 +152,7 @@ if (/@xmanrui\/dsh-(?:feishu|weixin|dingtalk)/.test(host)) {
115
152
  throw new Error('host bundle still imports an external channel plugin');
116
153
  }
117
154
  if (/@xmanrui\/dsh-(?:feishu|weixin|dingtalk)/.test(
118
- manifestText + lockText + hostSource + clientSource,
155
+ manifestText + lockText + hostSource + clientSources,
119
156
  )) {
120
157
  throw new Error('source or package metadata still depends on an external channel plugin');
121
158
  }
@@ -8,8 +8,14 @@ export const DINGTALK_REGISTRATION_BASE_URL = 'https://oapi.dingtalk.com/';
8
8
  export const DINGTALK_API_BASE_URL = 'https://api.dingtalk.com/';
9
9
  export const DINGTALK_REGISTRATION_SOURCE = 'DING_DWS_CLAW';
10
10
  export const DINGTALK_AI_CARD_TEMPLATE_ID = '02fcf2f4-5e02-4a85-b672-46d1f715543e.schema';
11
+ export const DINGTALK_THINKING_REACTION_NAME = '🤔思考中';
12
+ export const DINGTALK_DONE_REACTION_NAME = '✅已完成';
13
+ export const DINGTALK_ERROR_REACTION_NAME = '❌处理失败';
11
14
 
12
15
  const DEFAULT_TIMEOUT_MS = 15_000;
16
+ const REACTION_TIMEOUT_MS = 5_000;
17
+ const TEXT_REACTION_ID = '2659900';
18
+ const TEXT_REACTION_BACKGROUND_ID = 'im_bg_1';
13
19
  const REGISTRATION_STATUSES = new Set(['WAITING', 'SUCCESS', 'FAIL', 'EXPIRED']);
14
20
 
15
21
  export class DingtalkApiError extends Error {
@@ -406,18 +412,20 @@ export function createDingtalkApi({
406
412
  if (!source) throw new TypeError('registrationSource is required');
407
413
  const tokenCache = new Map();
408
414
  const tokenRequests = new Map();
415
+ const reactionTokenRequests = new Map();
409
416
  let cardSlotTail = Promise.resolve();
410
417
  let nextCardRequestAt = 0;
411
418
 
412
419
  const endpoint = (base, pathname) => new URL(pathname.replace(/^\//, ''), base);
413
420
 
414
- async function accessToken({ clientId, clientSecret, signal }) {
421
+ async function accessToken({ clientId, clientSecret, signal, requestKind = 'normal' }) {
415
422
  const appKey = nonEmptyString(clientId);
416
423
  const appSecret = nonEmptyString(clientSecret);
417
424
  if (!appKey || !appSecret) throw new TypeError('clientId and clientSecret are required');
418
425
  const cached = tokenCache.get(appKey);
419
426
  if (cached && cached.expiresAt > now()) return cached.token;
420
- if (tokenRequests.has(appKey)) return tokenRequests.get(appKey);
427
+ const requests = requestKind === 'reaction' ? reactionTokenRequests : tokenRequests;
428
+ if (requests.has(appKey)) return requests.get(appKey);
421
429
 
422
430
  const request = (async () => {
423
431
  const value = await requestJson(fetchImpl, endpoint(apiBase, 'v1.0/oauth2/accessToken'), {
@@ -431,9 +439,75 @@ export function createDingtalkApi({
431
439
  const refreshAfterMs = Math.max(1_000, (expiresInSeconds - 60) * 1_000);
432
440
  tokenCache.set(appKey, { token, expiresAt: now() + refreshAfterMs });
433
441
  return token;
434
- })().finally(() => tokenRequests.delete(appKey));
435
- tokenRequests.set(appKey, request);
436
- return request;
442
+ })();
443
+ const shared = request.finally(() => requests.delete(appKey));
444
+ requests.set(appKey, shared);
445
+ return shared;
446
+ }
447
+
448
+ async function changeReaction({
449
+ clientId,
450
+ clientSecret,
451
+ robotCode,
452
+ messageId,
453
+ conversationId,
454
+ reactionName = DINGTALK_THINKING_REACTION_NAME,
455
+ signal,
456
+ }, action) {
457
+ const appKey = nonEmptyString(clientId);
458
+ const appSecret = nonEmptyString(clientSecret);
459
+ const botCode = nonEmptyString(robotCode) ?? appKey;
460
+ const openMsgId = nonEmptyString(messageId);
461
+ const openConversationId = nonEmptyString(conversationId);
462
+ const emotionName = nonEmptyString(reactionName);
463
+ if (!appKey || !appSecret) throw new TypeError('clientId and clientSecret are required');
464
+ if (!botCode) throw new TypeError('robotCode is required');
465
+ if (!openMsgId || !openConversationId) {
466
+ throw new TypeError('messageId and conversationId are required');
467
+ }
468
+ if (!emotionName) throw new TypeError('reactionName is required');
469
+ // Reactions deduplicate with each other but never own the normal reply's
470
+ // in-flight token request. Both paths may still reuse a cached token.
471
+ const token = await accessToken({
472
+ clientId: appKey,
473
+ clientSecret: appSecret,
474
+ signal,
475
+ requestKind: 'reaction',
476
+ });
477
+ const response = await requestJson(
478
+ fetchImpl,
479
+ endpoint(apiBase, `v1.0/robot/emotion/${action}`),
480
+ {
481
+ body: {
482
+ robotCode: botCode,
483
+ openMsgId,
484
+ openConversationId,
485
+ emotionType: 2,
486
+ emotionName,
487
+ textEmotion: {
488
+ emotionId: TEXT_REACTION_ID,
489
+ emotionName,
490
+ text: emotionName,
491
+ backgroundId: TEXT_REACTION_BACKGROUND_ID,
492
+ },
493
+ },
494
+ headers: { 'x-acs-dingtalk-access-token': token },
495
+ signal,
496
+ timeoutMs: REACTION_TIMEOUT_MS,
497
+ action: action === 'reply' ? '消息状态添加' : '消息状态撤回',
498
+ },
499
+ );
500
+ const rejection = response?.success === false
501
+ ? safeProviderCode(response?.code ?? response?.errcode) ?? 'rejected'
502
+ : rejectedProviderResponse(response);
503
+ if (rejection) {
504
+ throw new DingtalkApiError(
505
+ 'reaction-rejected',
506
+ '钉钉服务拒绝了消息状态请求。',
507
+ { providerCode: rejection },
508
+ );
509
+ }
510
+ return true;
437
511
  }
438
512
 
439
513
  async function messageFileDownloadUrl({
@@ -690,6 +764,28 @@ export function createDingtalkApi({
690
764
 
691
765
  accessToken,
692
766
 
767
+ async addReaction(request) {
768
+ return changeReaction(request, 'reply');
769
+ },
770
+
771
+ async recallReaction(request) {
772
+ return changeReaction(request, 'recall');
773
+ },
774
+
775
+ async addThinkingReaction(request) {
776
+ return changeReaction({
777
+ ...request,
778
+ reactionName: DINGTALK_THINKING_REACTION_NAME,
779
+ }, 'reply');
780
+ },
781
+
782
+ async recallThinkingReaction(request) {
783
+ return changeReaction({
784
+ ...request,
785
+ reactionName: DINGTALK_THINKING_REACTION_NAME,
786
+ }, 'recall');
787
+ },
788
+
693
789
  async downloadImage({
694
790
  clientId,
695
791
  clientSecret,