@soimy/dingtalk 3.2.0 → 3.4.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.
- package/LICENSE +21 -0
- package/README.md +796 -42
- package/index.ts +62 -0
- package/package.json +4 -2
- package/src/access-control.ts +83 -0
- package/src/ack-reaction/dynamic-ack-reaction-controller.ts +271 -0
- package/src/ack-reaction/dynamic-ack-reaction-events.ts +123 -0
- package/src/ack-reaction/dynamic-ack-reaction-progress.ts +59 -0
- package/src/ack-reaction-classifier.ts +75 -0
- package/src/ack-reaction-service.ts +182 -0
- package/src/attachment-text-extractor.ts +148 -0
- package/src/card-callback-service.ts +119 -0
- package/src/card-draft-controller.ts +114 -0
- package/src/card-service.ts +666 -26
- package/src/channel.ts +455 -150
- package/src/config-schema.ts +64 -6
- package/src/config.ts +161 -5
- package/src/connection-manager.ts +354 -47
- package/src/dedup.ts +1 -0
- package/src/docs-service.ts +198 -0
- package/src/draft-stream-loop.ts +119 -0
- package/src/feedback-learning-service.ts +643 -0
- package/src/feedback-learning-store.ts +543 -0
- package/src/group-members-store.ts +48 -14
- package/src/inbound-handler.ts +1374 -259
- package/src/learning-command-service.ts +339 -0
- package/src/media-utils.ts +94 -50
- package/src/message-context-store.ts +787 -0
- package/src/message-utils.ts +487 -46
- package/src/messaging/quoted-context.ts +269 -0
- package/src/messaging/quoted-ref.ts +97 -0
- package/src/onboarding.ts +96 -1
- package/src/peer-id-registry.ts +102 -0
- package/src/persistence-store.ts +131 -0
- package/src/quoted-file-service.ts +385 -0
- package/src/reply-strategy-card.ts +225 -0
- package/src/reply-strategy-markdown.ts +55 -0
- package/src/reply-strategy-with-reaction.ts +190 -0
- package/src/reply-strategy.ts +72 -0
- package/src/send-service.ts +267 -45
- package/src/session-command-service.ts +147 -0
- package/src/session-lock.ts +2 -0
- package/src/session-peer-store.ts +77 -0
- package/src/session-routing.ts +33 -0
- package/src/targeting/agent-name-matcher.ts +148 -0
- package/src/targeting/agent-routing.ts +181 -0
- package/src/targeting/target-directory-adapter.ts +151 -0
- package/src/targeting/target-directory-store.ts +396 -0
- package/src/targeting/target-input.ts +62 -0
- package/src/types.ts +261 -28
- package/src/utils.ts +231 -12
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { DingTalkConfig } from "./types";
|
|
2
|
+
|
|
3
|
+
export interface ResolveDingTalkSessionPeerParams {
|
|
4
|
+
isDirect: boolean;
|
|
5
|
+
senderId: string;
|
|
6
|
+
conversationId: string;
|
|
7
|
+
peerIdOverride?: string;
|
|
8
|
+
config: DingTalkConfig;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ResolvedDingTalkSessionPeer {
|
|
12
|
+
kind: "direct" | "group";
|
|
13
|
+
peerId: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Keep DingTalk aligned with Feishu's explicit peerId -> sessionKey model:
|
|
17
|
+
// resolve a stable peer identity first, then let OpenClaw build the final session key.
|
|
18
|
+
export function resolveDingTalkSessionPeer(
|
|
19
|
+
params: ResolveDingTalkSessionPeerParams,
|
|
20
|
+
): ResolvedDingTalkSessionPeer {
|
|
21
|
+
const normalizedPeerIdOverride = params.peerIdOverride?.trim();
|
|
22
|
+
if (params.isDirect) {
|
|
23
|
+
return {
|
|
24
|
+
kind: "direct",
|
|
25
|
+
peerId: normalizedPeerIdOverride || params.senderId,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
kind: "group",
|
|
31
|
+
peerId: normalizedPeerIdOverride || params.conversationId,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent name matcher for @sub-agent feature
|
|
3
|
+
*
|
|
4
|
+
* Matches @mentions to agent IDs based on name and id fields in agents.list config.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
8
|
+
import type { AtMention, AgentNameMatch } from "../types";
|
|
9
|
+
|
|
10
|
+
interface AgentConfig {
|
|
11
|
+
id: string;
|
|
12
|
+
name?: string;
|
|
13
|
+
default?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Normalize name for case-insensitive matching
|
|
18
|
+
*/
|
|
19
|
+
function normalizeName(name: string): string {
|
|
20
|
+
return name.trim().toLowerCase();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Get main agent ID from agents.list
|
|
25
|
+
*/
|
|
26
|
+
export function getMainAgentId(agents: AgentConfig[] | undefined): string {
|
|
27
|
+
if (!agents || agents.length === 0) {
|
|
28
|
+
return "main";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const defaultAgent = agents.find((a) => a.default);
|
|
32
|
+
if (defaultAgent) {
|
|
33
|
+
return defaultAgent.id;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return agents[0].id;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Match a single @name to an agent
|
|
41
|
+
*/
|
|
42
|
+
function matchAtName(atName: string, agents: AgentConfig[]): AgentNameMatch | null {
|
|
43
|
+
const normalizedAtName = normalizeName(atName);
|
|
44
|
+
|
|
45
|
+
for (const agent of agents) {
|
|
46
|
+
// 1. Match by name field
|
|
47
|
+
if (agent.name && normalizeName(agent.name) === normalizedAtName) {
|
|
48
|
+
return {
|
|
49
|
+
agentId: agent.id,
|
|
50
|
+
matchSource: "name",
|
|
51
|
+
matchedName: agent.name,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 2. Match by id field
|
|
56
|
+
if (normalizeName(agent.id) === normalizedAtName) {
|
|
57
|
+
return {
|
|
58
|
+
agentId: agent.id,
|
|
59
|
+
matchSource: "id",
|
|
60
|
+
matchedName: agent.id,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Resolve @mentions to agent matches
|
|
70
|
+
*
|
|
71
|
+
* @param atMentions - List of @mentions extracted from message
|
|
72
|
+
* @param cfg - OpenClaw configuration
|
|
73
|
+
* @param atUserDingtalkIds - dingtalkIds from webhook atUsers field (real users selected via @picker)
|
|
74
|
+
* @returns Matched agents, unmatched names, main agent ID, and whether there are invalid agent names
|
|
75
|
+
*
|
|
76
|
+
* @remarks
|
|
77
|
+
* Exclusion logic for unmatched @mentions:
|
|
78
|
+
* - If mention.userId is set (from richText), it's a real user → excluded from unmatchedNames
|
|
79
|
+
* - In text mode, we cannot map dingtalkIds to specific @mention names
|
|
80
|
+
* - To avoid false positives (reporting real user names as missing agents),
|
|
81
|
+
* we use a conservative heuristic:
|
|
82
|
+
* - If realUserCount > 0, hasInvalidAgentNames is always false
|
|
83
|
+
* - This means we never report "agent not found" in text mode when there are real users
|
|
84
|
+
* - In richText mode, mention.userId allows precise exclusion, so the heuristic is not needed
|
|
85
|
+
*/
|
|
86
|
+
export function resolveAtAgents(
|
|
87
|
+
atMentions: AtMention[],
|
|
88
|
+
cfg: OpenClawConfig,
|
|
89
|
+
atUserDingtalkIds?: string[],
|
|
90
|
+
): {
|
|
91
|
+
matchedAgents: AgentNameMatch[];
|
|
92
|
+
unmatchedNames: string[];
|
|
93
|
+
mainAgentId: string;
|
|
94
|
+
/** Count of @mentions that are likely real users (from atUserDingtalkIds) */
|
|
95
|
+
realUserCount: number;
|
|
96
|
+
/** Whether there are invalid agent names (conservative: false if realUserCount > 0) */
|
|
97
|
+
hasInvalidAgentNames: boolean;
|
|
98
|
+
} {
|
|
99
|
+
const agents = cfg?.agents?.list as AgentConfig[] | undefined;
|
|
100
|
+
const mainAgentId = getMainAgentId(agents);
|
|
101
|
+
|
|
102
|
+
if (!atMentions || atMentions.length === 0) {
|
|
103
|
+
return {
|
|
104
|
+
matchedAgents: [],
|
|
105
|
+
unmatchedNames: [],
|
|
106
|
+
mainAgentId,
|
|
107
|
+
realUserCount: 0,
|
|
108
|
+
hasInvalidAgentNames: false,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const matchedAgents: AgentNameMatch[] = [];
|
|
113
|
+
const unmatchedNames: string[] = [];
|
|
114
|
+
|
|
115
|
+
for (const mention of atMentions) {
|
|
116
|
+
const match = agents ? matchAtName(mention.name, agents) : null;
|
|
117
|
+
|
|
118
|
+
if (match) {
|
|
119
|
+
// Avoid duplicate agents
|
|
120
|
+
if (!matchedAgents.some((m) => m.agentId === match.agentId)) {
|
|
121
|
+
matchedAgents.push(match);
|
|
122
|
+
}
|
|
123
|
+
} else {
|
|
124
|
+
// Exclude @real users (those with userId are real users from richText)
|
|
125
|
+
if (!mention.userId) {
|
|
126
|
+
unmatchedNames.push(mention.name);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Count real users from atUserDingtalkIds
|
|
132
|
+
// These are real DingTalk users selected via @picker, but we don't know which names they correspond to
|
|
133
|
+
const realUserCount = atUserDingtalkIds?.length || 0;
|
|
134
|
+
|
|
135
|
+
// Conservative heuristic: if there are real users, never report invalid agent names
|
|
136
|
+
// This avoids false positives where real user names are incorrectly reported as missing agents
|
|
137
|
+
// In text mode, we cannot distinguish which @mentions correspond to real users
|
|
138
|
+
// In richText mode, mention.userId provides precise exclusion, so this is just a safety net
|
|
139
|
+
const hasInvalidAgentNames = realUserCount === 0 && unmatchedNames.length > 0;
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
matchedAgents,
|
|
143
|
+
unmatchedNames,
|
|
144
|
+
mainAgentId,
|
|
145
|
+
realUserCount,
|
|
146
|
+
hasInvalidAgentNames,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sub-agent routing for @mention-based multi-agent support.
|
|
3
|
+
*
|
|
4
|
+
* Extracts @mentions from inbound messages and resolves them to agent IDs
|
|
5
|
+
* using agents.list configuration. This is a plugin-layer routing mechanism
|
|
6
|
+
* because the framework's resolveAgentRoute only supports static matching
|
|
7
|
+
* (channel + accountId + peer), not content-based dynamic routing.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
11
|
+
import { resolveAtAgents } from "./agent-name-matcher";
|
|
12
|
+
import { parseLearnCommand } from "../learning-command-service";
|
|
13
|
+
import { getDingTalkRuntime } from "../runtime";
|
|
14
|
+
import { sendBySession } from "../send-service";
|
|
15
|
+
import type { AgentNameMatch, DingTalkConfig, DingTalkInboundMessage, HandleDingTalkMessageParams, Logger, MessageContent } from "../types";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Build a session key for a specific agent using the runtime API.
|
|
19
|
+
* Falls back to framework's resolveAgentRoute if buildAgentSessionKey is unavailable.
|
|
20
|
+
*/
|
|
21
|
+
export function buildAgentSessionKey(params: {
|
|
22
|
+
rt: ReturnType<typeof getDingTalkRuntime>;
|
|
23
|
+
cfg: OpenClawConfig;
|
|
24
|
+
accountId: string;
|
|
25
|
+
agentId: string;
|
|
26
|
+
peerKind: "direct" | "group";
|
|
27
|
+
peerId: string;
|
|
28
|
+
}): string {
|
|
29
|
+
const { rt, cfg, accountId, agentId, peerKind, peerId } = params;
|
|
30
|
+
const routing = rt.channel.routing as Record<string, unknown>;
|
|
31
|
+
if (typeof routing.buildAgentSessionKey === "function") {
|
|
32
|
+
return (
|
|
33
|
+
(routing.buildAgentSessionKey as (p: unknown) => string)({
|
|
34
|
+
agentId,
|
|
35
|
+
channel: "dingtalk",
|
|
36
|
+
accountId,
|
|
37
|
+
peer: { kind: peerKind, id: peerId },
|
|
38
|
+
dmScope: cfg.session?.dmScope,
|
|
39
|
+
identityLinks: cfg.session?.identityLinks,
|
|
40
|
+
})
|
|
41
|
+
).toLowerCase();
|
|
42
|
+
}
|
|
43
|
+
// Fallback: derive a session key with agentId suffix to ensure isolation.
|
|
44
|
+
// resolveAgentRoute routes to the default agent, so we append the target
|
|
45
|
+
// agentId to prevent session key collisions between sub-agents.
|
|
46
|
+
// @migration-note: When SDK exposes buildAgentSessionKey in type definitions,
|
|
47
|
+
// sessions created via this fallback path will become orphaned. Remove this
|
|
48
|
+
// fallback and the typeof check once the SDK is updated.
|
|
49
|
+
const fallbackRoute = rt.channel.routing.resolveAgentRoute({
|
|
50
|
+
cfg,
|
|
51
|
+
channel: "dingtalk",
|
|
52
|
+
accountId,
|
|
53
|
+
peer: { kind: peerKind, id: peerId },
|
|
54
|
+
});
|
|
55
|
+
return `${fallbackRoute.sessionKey}:subagent:${agentId}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Sanitize agent name for safe use in markdown prefix and context hints.
|
|
60
|
+
* Strips brackets, newlines, and control characters to prevent markdown
|
|
61
|
+
* breakage and prompt injection.
|
|
62
|
+
*/
|
|
63
|
+
function sanitizeAgentName(name: string): string {
|
|
64
|
+
return name.replace(/[[\]\r\n]/g, "").trim();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Resolve @mention-based sub-agent routing for a group message.
|
|
69
|
+
*
|
|
70
|
+
* Returns matched agents if any @mentions resolve to configured agents,
|
|
71
|
+
* or null if the message should be handled by the default agent.
|
|
72
|
+
*/
|
|
73
|
+
export async function resolveSubAgentRoute(params: {
|
|
74
|
+
extractedContent: MessageContent;
|
|
75
|
+
cfg: OpenClawConfig;
|
|
76
|
+
isGroup: boolean;
|
|
77
|
+
dingtalkConfig: DingTalkConfig;
|
|
78
|
+
sessionWebhook: string;
|
|
79
|
+
senderId: string;
|
|
80
|
+
log?: Logger;
|
|
81
|
+
}): Promise<{
|
|
82
|
+
matchedAgents: AgentNameMatch[];
|
|
83
|
+
preDownloadedMedia?: { mediaPath?: string; mediaType?: string };
|
|
84
|
+
} | null> {
|
|
85
|
+
const { extractedContent, cfg, isGroup, dingtalkConfig, sessionWebhook, senderId, log } = params;
|
|
86
|
+
|
|
87
|
+
const atMentions = extractedContent.atMentions || [];
|
|
88
|
+
const atUserDingtalkIds = extractedContent.atUserDingtalkIds;
|
|
89
|
+
// Strip quoted prefix before checking /learn to avoid false positives
|
|
90
|
+
// when the quoted message itself contains a /learn command.
|
|
91
|
+
const textForCommandCheck = extractedContent.text.replace(/^\[引用[^\]]*\]\s*/, "");
|
|
92
|
+
const isLearnCommand = parseLearnCommand(textForCommandCheck).scope !== "unknown";
|
|
93
|
+
|
|
94
|
+
if (
|
|
95
|
+
!isGroup ||
|
|
96
|
+
atMentions.length === 0 ||
|
|
97
|
+
!cfg.agents?.list ||
|
|
98
|
+
cfg.agents.list.length === 0 ||
|
|
99
|
+
isLearnCommand
|
|
100
|
+
) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const { matchedAgents, unmatchedNames, realUserCount, hasInvalidAgentNames } = resolveAtAgents(
|
|
105
|
+
atMentions,
|
|
106
|
+
cfg,
|
|
107
|
+
atUserDingtalkIds,
|
|
108
|
+
);
|
|
109
|
+
log?.info?.(
|
|
110
|
+
`[DingTalk] Sub-agent resolve: matched=${matchedAgents.map((a) => a.agentId).join(",")} unmatched=${unmatchedNames.join(",")} realUsers=${realUserCount}`,
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
// Send fallback notice for unmatched agent names
|
|
114
|
+
if (hasInvalidAgentNames) {
|
|
115
|
+
const fallbackReason = `未找到名为"${unmatchedNames.join("、")}"的助手`;
|
|
116
|
+
try {
|
|
117
|
+
await sendBySession(dingtalkConfig, sessionWebhook, `⚠️ ${fallbackReason}`, {
|
|
118
|
+
atUserId: senderId,
|
|
119
|
+
log,
|
|
120
|
+
});
|
|
121
|
+
} catch (err: any) {
|
|
122
|
+
log?.debug?.(`[DingTalk] Failed to send fallback notice: ${err.message}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (matchedAgents.length === 0) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return { matchedAgents };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Process matched sub-agents by dispatching each to handleDingTalkMessage.
|
|
135
|
+
*/
|
|
136
|
+
export async function dispatchSubAgents(params: {
|
|
137
|
+
matchedAgents: AgentNameMatch[];
|
|
138
|
+
cfg: OpenClawConfig;
|
|
139
|
+
accountId: string;
|
|
140
|
+
data: DingTalkInboundMessage;
|
|
141
|
+
dingtalkConfig: DingTalkConfig;
|
|
142
|
+
sessionWebhook: string;
|
|
143
|
+
extractedContent: MessageContent;
|
|
144
|
+
handleMessage: (params: HandleDingTalkMessageParams) => Promise<void>;
|
|
145
|
+
downloadMedia: (config: DingTalkConfig, mediaPath: string, log?: Logger) => Promise<{ path: string; mimeType: string } | null>;
|
|
146
|
+
log?: Logger;
|
|
147
|
+
}): Promise<void> {
|
|
148
|
+
const { matchedAgents, cfg, accountId, data, dingtalkConfig, sessionWebhook, extractedContent, handleMessage, downloadMedia: download, log } = params;
|
|
149
|
+
|
|
150
|
+
// Pre-download media once to avoid duplication across sub-agents
|
|
151
|
+
let preDownloadedMedia: { mediaPath?: string; mediaType?: string } | undefined;
|
|
152
|
+
if (extractedContent.mediaPath && dingtalkConfig.robotCode) {
|
|
153
|
+
const media = await download(dingtalkConfig, extractedContent.mediaPath, log);
|
|
154
|
+
if (media) {
|
|
155
|
+
preDownloadedMedia = { mediaPath: media.path, mediaType: media.mimeType };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
for (const agentMatch of matchedAgents) {
|
|
160
|
+
try {
|
|
161
|
+
await handleMessage({
|
|
162
|
+
cfg,
|
|
163
|
+
accountId,
|
|
164
|
+
data,
|
|
165
|
+
sessionWebhook,
|
|
166
|
+
log,
|
|
167
|
+
dingtalkConfig,
|
|
168
|
+
subAgentOptions: {
|
|
169
|
+
agentId: agentMatch.agentId,
|
|
170
|
+
responsePrefix: `[${sanitizeAgentName(agentMatch.matchedName)}] `,
|
|
171
|
+
matchedName: agentMatch.matchedName,
|
|
172
|
+
},
|
|
173
|
+
preDownloadedMedia,
|
|
174
|
+
});
|
|
175
|
+
} catch (error) {
|
|
176
|
+
log?.error?.(
|
|
177
|
+
`[DingTalk] Sub-agent ${agentMatch.agentId} failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import type { ChannelDirectoryEntry, OpenClawConfig } from "openclaw/plugin-sdk";
|
|
2
|
+
import { getConfig, stripTargetPrefix } from "../config";
|
|
3
|
+
import { resolveOriginalPeerId } from "../peer-id-registry";
|
|
4
|
+
import { getDingTalkRuntime } from "../runtime";
|
|
5
|
+
import { listKnownGroupTargets, listKnownUserTargets } from "./target-directory-store";
|
|
6
|
+
|
|
7
|
+
export type DirectoryListParams = {
|
|
8
|
+
cfg: OpenClawConfig;
|
|
9
|
+
accountId?: string | null;
|
|
10
|
+
query?: string | null;
|
|
11
|
+
limit?: number | null;
|
|
12
|
+
runtime?: unknown;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type RuntimeSessionResolver = {
|
|
16
|
+
resolveStorePath?: (store: unknown, options: { agentId?: string | null | undefined }) => string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function normalizeDirectoryAccountId(accountId?: string | null): string {
|
|
20
|
+
const resolved = String(accountId || "default").trim();
|
|
21
|
+
return resolved || "default";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function normalizeDirectoryLimit(limit?: number | null): number | undefined {
|
|
25
|
+
if (typeof limit !== "number" || !Number.isFinite(limit) || limit <= 0) {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
return Math.floor(limit);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function resolveDirectoryStorePath(params: {
|
|
32
|
+
cfg: OpenClawConfig;
|
|
33
|
+
accountId?: string | null;
|
|
34
|
+
runtime?: unknown;
|
|
35
|
+
}): string | undefined {
|
|
36
|
+
const normalizedAccountId = normalizeDirectoryAccountId(params.accountId);
|
|
37
|
+
const runtimeSession = (
|
|
38
|
+
params.runtime as { channel?: { session?: RuntimeSessionResolver } } | undefined
|
|
39
|
+
)?.channel?.session;
|
|
40
|
+
if (runtimeSession?.resolveStorePath) {
|
|
41
|
+
return runtimeSession.resolveStorePath(params.cfg.session?.store, {
|
|
42
|
+
agentId: normalizedAccountId,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
const rt = getDingTalkRuntime();
|
|
47
|
+
return rt.channel.session.resolveStorePath(params.cfg.session?.store, {
|
|
48
|
+
agentId: normalizedAccountId,
|
|
49
|
+
});
|
|
50
|
+
} catch {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function shouldFilterDirectoryByQuery(params: DirectoryListParams): boolean {
|
|
56
|
+
// OpenClaw target-resolver currently uses a query-insensitive cache key and
|
|
57
|
+
// always calls directory list APIs with limit=undefined. If we filter by
|
|
58
|
+
// query at this layer during resolver calls, one miss can poison cache for
|
|
59
|
+
// later different queries. Keep resolver reads query-agnostic.
|
|
60
|
+
return params.limit !== undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isDisplayNameResolutionEnabled(params: {
|
|
64
|
+
cfg: OpenClawConfig;
|
|
65
|
+
accountId?: string | null;
|
|
66
|
+
}): boolean {
|
|
67
|
+
const mode = getConfig(params.cfg, params.accountId ?? undefined).displayNameResolution;
|
|
68
|
+
// Current upstream target resolution does not pass requester owner/authz context into
|
|
69
|
+
// plugin targetResolver/directory callbacks, so owner-only resolution is not yet safe.
|
|
70
|
+
// Keep the config explicit: only "all" enables learned displayName resolution for now.
|
|
71
|
+
// TODO(upstream target-resolver): add a true owner-only mode once requester authorization
|
|
72
|
+
// is plumbed into plugin resolver and directory entry points.
|
|
73
|
+
return mode === "all";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function listDingTalkDirectoryGroups(params: DirectoryListParams): ChannelDirectoryEntry[] {
|
|
77
|
+
if (!isDisplayNameResolutionEnabled(params)) {
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
const accountId = normalizeDirectoryAccountId(params.accountId);
|
|
81
|
+
const storePath = resolveDirectoryStorePath(params);
|
|
82
|
+
const filterByQuery = shouldFilterDirectoryByQuery(params);
|
|
83
|
+
const groups = listKnownGroupTargets({
|
|
84
|
+
storePath,
|
|
85
|
+
accountId,
|
|
86
|
+
query: filterByQuery ? (params.query ?? undefined) : undefined,
|
|
87
|
+
limit: filterByQuery ? normalizeDirectoryLimit(params.limit) : undefined,
|
|
88
|
+
});
|
|
89
|
+
const groupEntries: ChannelDirectoryEntry[] = groups.map((entry) => ({
|
|
90
|
+
kind: "group" as const,
|
|
91
|
+
id: resolveOriginalPeerId(entry.conversationId),
|
|
92
|
+
name: entry.currentTitle,
|
|
93
|
+
handle: entry.conversationId,
|
|
94
|
+
rank: entry.lastSeenAt,
|
|
95
|
+
raw: entry,
|
|
96
|
+
}));
|
|
97
|
+
|
|
98
|
+
if (filterByQuery) {
|
|
99
|
+
return groupEntries;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// TODO(upstream target-resolver): remove this fallback once bare user labels
|
|
103
|
+
// can be classified and routed to listPeers() instead of only listGroups().
|
|
104
|
+
// Temporary hack for the current upstream target-resolver flow: bare names
|
|
105
|
+
// are classified as "group" before directory lookup, so user displayName
|
|
106
|
+
// targets never reach listPeers(). Merge users into the resolver-only group
|
|
107
|
+
// lookup set so "displayName -> targetId" can still resolve for DingTalk.
|
|
108
|
+
const users = listKnownUserTargets({
|
|
109
|
+
storePath,
|
|
110
|
+
accountId,
|
|
111
|
+
});
|
|
112
|
+
const userEntries: ChannelDirectoryEntry[] = users.map((entry) => ({
|
|
113
|
+
kind: "user" as const,
|
|
114
|
+
id: entry.canonicalUserId,
|
|
115
|
+
name: entry.currentDisplayName,
|
|
116
|
+
handle: entry.staffId || entry.senderId,
|
|
117
|
+
rank: entry.lastSeenAt,
|
|
118
|
+
raw: entry,
|
|
119
|
+
}));
|
|
120
|
+
|
|
121
|
+
return [...groupEntries, ...userEntries].toSorted(
|
|
122
|
+
(left, right) => (right.rank || 0) - (left.rank || 0),
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function listDingTalkDirectoryUsers(params: DirectoryListParams): ChannelDirectoryEntry[] {
|
|
127
|
+
if (!isDisplayNameResolutionEnabled(params)) {
|
|
128
|
+
return [];
|
|
129
|
+
}
|
|
130
|
+
const accountId = normalizeDirectoryAccountId(params.accountId);
|
|
131
|
+
const storePath = resolveDirectoryStorePath(params);
|
|
132
|
+
const filterByQuery = shouldFilterDirectoryByQuery(params);
|
|
133
|
+
const users = listKnownUserTargets({
|
|
134
|
+
storePath,
|
|
135
|
+
accountId,
|
|
136
|
+
query: filterByQuery ? (params.query ?? undefined) : undefined,
|
|
137
|
+
limit: filterByQuery ? normalizeDirectoryLimit(params.limit) : undefined,
|
|
138
|
+
});
|
|
139
|
+
return users.map((entry) => ({
|
|
140
|
+
kind: "user",
|
|
141
|
+
id: entry.canonicalUserId,
|
|
142
|
+
name: entry.currentDisplayName,
|
|
143
|
+
handle: entry.staffId || entry.senderId,
|
|
144
|
+
rank: entry.lastSeenAt,
|
|
145
|
+
raw: entry,
|
|
146
|
+
}));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function normalizeResolvedDingTalkTarget(raw: string): string {
|
|
150
|
+
return resolveOriginalPeerId(stripTargetPrefix(raw).targetId);
|
|
151
|
+
}
|