@soimy/dingtalk 3.6.10 → 3.6.11
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/dist/index.js +232 -2
- package/dist/src/docs-service.d.ts +4 -4
- package/dist/src/messaging/inline-directives.d.ts +42 -0
- package/dist/src/types.d.ts +1 -1
- package/package.json +1 -1
- package/src/inbound-handler.ts +6 -2
- package/src/messaging/inline-directives.ts +342 -0
- package/src/types.ts +1 -1
package/dist/index.js
CHANGED
|
@@ -1035,8 +1035,238 @@ async function updateCardVariables(outTrackId, params, token, config) {
|
|
|
1035
1035
|
// src/inbound-handler.ts
|
|
1036
1036
|
import fs6 from "node:fs";
|
|
1037
1037
|
import * as path10 from "node:path";
|
|
1038
|
+
import { formatInboundEnvelope } from "openclaw/plugin-sdk/channel-inbound";
|
|
1038
1039
|
import { isAbortRequestText, isBtwRequestText } from "openclaw/plugin-sdk/reply-runtime";
|
|
1039
|
-
|
|
1040
|
+
|
|
1041
|
+
// src/messaging/inline-directives.ts
|
|
1042
|
+
import { findCodeRegions, isInsideCode } from "openclaw/plugin-sdk/text-chunking";
|
|
1043
|
+
var AUDIO_TAG_RE = /\[\[\s*audio_as_voice\s*\]\]/gi;
|
|
1044
|
+
var REPLY_TAG_RE = /\[\[\s*(?:reply_to_current|reply_to\s*:\s*([^\]\n]+))\s*\]\]/gi;
|
|
1045
|
+
var MAX_REPLY_DIRECTIVE_ID_LENGTH = 256;
|
|
1046
|
+
var BLOCK_SENTINEL_SEED = "\uE000";
|
|
1047
|
+
var INDENTED_CODE_LINE_RE = /(?:^|\n)((?: {4}|\t)[^\n]*)(?:\n(?: {4}|\t)[^\n]*)*/g;
|
|
1048
|
+
function normalizeOptionalString(value) {
|
|
1049
|
+
if (typeof value !== "string") {
|
|
1050
|
+
return void 0;
|
|
1051
|
+
}
|
|
1052
|
+
const trimmed = value.trim();
|
|
1053
|
+
return trimmed ? trimmed : void 0;
|
|
1054
|
+
}
|
|
1055
|
+
function resolveCodeRegions(text) {
|
|
1056
|
+
const indentedRegions = [];
|
|
1057
|
+
const indentRe = new RegExp(INDENTED_CODE_LINE_RE.source, "g");
|
|
1058
|
+
let match;
|
|
1059
|
+
while ((match = indentRe.exec(text)) !== null) {
|
|
1060
|
+
const start = match.index + (match[0].charCodeAt(0) === 10 ? 1 : 0);
|
|
1061
|
+
indentedRegions.push({ start, end: match.index + match[0].length });
|
|
1062
|
+
}
|
|
1063
|
+
const sdkRegions = findCodeRegions(text).filter(
|
|
1064
|
+
(region) => (text[region.start] !== "`" || !isEscaped(text, region.start)) && !indentedRegions.some(
|
|
1065
|
+
(indented) => region.start >= indented.start && region.start < indented.end
|
|
1066
|
+
)
|
|
1067
|
+
);
|
|
1068
|
+
const knownRegions = mergeCodeRegions([...sdkRegions, ...indentedRegions]);
|
|
1069
|
+
return mergeCodeRegions([...knownRegions, ...findDelimiterCodeRegions(text, knownRegions)]);
|
|
1070
|
+
}
|
|
1071
|
+
function isEscaped(text, offset) {
|
|
1072
|
+
let slashes = 0;
|
|
1073
|
+
for (let index = offset - 1; text[index] === "\\"; index--) {
|
|
1074
|
+
slashes++;
|
|
1075
|
+
}
|
|
1076
|
+
return slashes % 2 === 1;
|
|
1077
|
+
}
|
|
1078
|
+
function isStrictlyInsideCode(offset, regions) {
|
|
1079
|
+
return regions.some((region) => offset > region.start && offset < region.end);
|
|
1080
|
+
}
|
|
1081
|
+
function findDelimiterCodeRegions(text, knownRegions) {
|
|
1082
|
+
const regions = [];
|
|
1083
|
+
let match;
|
|
1084
|
+
const fences = /^( {0,3})(`{3,}|~{3,})[^\n]*(?:\n|$)/gm;
|
|
1085
|
+
let open;
|
|
1086
|
+
while ((match = fences.exec(text)) !== null) {
|
|
1087
|
+
const delimiter = match[2];
|
|
1088
|
+
if (!open) {
|
|
1089
|
+
const suffix = match[0].slice(match[1].length + delimiter.length).trim();
|
|
1090
|
+
if (isStrictlyInsideCode(match.index, knownRegions) || delimiter[0] === "`" && suffix.includes("`")) {
|
|
1091
|
+
continue;
|
|
1092
|
+
}
|
|
1093
|
+
open = { char: delimiter[0], length: delimiter.length, start: match.index };
|
|
1094
|
+
} else if (delimiter[0] === open.char && delimiter.length >= open.length && match[0].slice(match[1].length + delimiter.length).trim() === "") {
|
|
1095
|
+
regions.push({ start: open.start, end: fences.lastIndex });
|
|
1096
|
+
open = void 0;
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
if (open) {
|
|
1100
|
+
regions.push({ start: open.start, end: text.length });
|
|
1101
|
+
}
|
|
1102
|
+
const fencedRegions = [...regions];
|
|
1103
|
+
const pendingTicks = /* @__PURE__ */ new Map();
|
|
1104
|
+
const ticks = /`+/g;
|
|
1105
|
+
while ((match = ticks.exec(text)) !== null) {
|
|
1106
|
+
const length = match[0].length;
|
|
1107
|
+
if (isEscaped(text, match.index) || isInsideCode(match.index, fencedRegions) || pendingTicks.get(length) === void 0 && isStrictlyInsideCode(match.index, knownRegions)) {
|
|
1108
|
+
continue;
|
|
1109
|
+
}
|
|
1110
|
+
const start = pendingTicks.get(length);
|
|
1111
|
+
if (start === void 0) {
|
|
1112
|
+
pendingTicks.set(length, match.index);
|
|
1113
|
+
continue;
|
|
1114
|
+
}
|
|
1115
|
+
regions.push({ start, end: ticks.lastIndex });
|
|
1116
|
+
for (const [pendingLength, pendingStart] of pendingTicks) {
|
|
1117
|
+
if (pendingStart >= start) {
|
|
1118
|
+
pendingTicks.delete(pendingLength);
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
return regions;
|
|
1123
|
+
}
|
|
1124
|
+
function mergeCodeRegions(regions) {
|
|
1125
|
+
if (regions.length <= 1) {
|
|
1126
|
+
return regions;
|
|
1127
|
+
}
|
|
1128
|
+
const sorted = [...regions].toSorted((a, b) => a.start - b.start || a.end - b.end);
|
|
1129
|
+
const merged = [];
|
|
1130
|
+
for (const region of sorted) {
|
|
1131
|
+
const last = merged[merged.length - 1];
|
|
1132
|
+
if (last && region.start <= last.end) {
|
|
1133
|
+
last.end = Math.max(last.end, region.end);
|
|
1134
|
+
} else {
|
|
1135
|
+
merged.push({ start: region.start, end: region.end });
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
return merged;
|
|
1139
|
+
}
|
|
1140
|
+
function createBlockSentinel(text) {
|
|
1141
|
+
let sentinel = BLOCK_SENTINEL_SEED;
|
|
1142
|
+
while (text.includes(sentinel)) {
|
|
1143
|
+
sentinel += BLOCK_SENTINEL_SEED;
|
|
1144
|
+
}
|
|
1145
|
+
return sentinel;
|
|
1146
|
+
}
|
|
1147
|
+
function normalizeDirectiveWhitespace(text, codeRegions = []) {
|
|
1148
|
+
const blockSentinel = createBlockSentinel(text);
|
|
1149
|
+
const blockPlaceholderRe = new RegExp(`${blockSentinel}(\\d+)${blockSentinel}`, "g");
|
|
1150
|
+
const blocks = [];
|
|
1151
|
+
let masked = "";
|
|
1152
|
+
let cursor = 0;
|
|
1153
|
+
for (const region of codeRegions) {
|
|
1154
|
+
if (region.start < cursor) {
|
|
1155
|
+
continue;
|
|
1156
|
+
}
|
|
1157
|
+
blocks.push(text.slice(region.start, region.end));
|
|
1158
|
+
masked += `${text.slice(cursor, region.start)}${blockSentinel}${blocks.length - 1}${blockSentinel}`;
|
|
1159
|
+
cursor = region.end;
|
|
1160
|
+
}
|
|
1161
|
+
masked += text.slice(cursor);
|
|
1162
|
+
return masked.replace(/\r\n/g, "\n").replace(/([^\s])[ \t]{2,}([^\s])/g, "$1 $2").replace(/^\n+/, "").replace(/^[ \t](?=\S)/, "").replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trimEnd().replace(blockPlaceholderRe, (_full, index) => blocks[Number(index)] ?? "");
|
|
1163
|
+
}
|
|
1164
|
+
function replacementPreservesWordBoundary(source, offset, length) {
|
|
1165
|
+
const before = source[offset - 1];
|
|
1166
|
+
const after = source[offset + length];
|
|
1167
|
+
return before && after && !/\s/u.test(before) && !/\s/u.test(after) ? " " : "";
|
|
1168
|
+
}
|
|
1169
|
+
function stripUnsafeReplyDirectiveChars(value) {
|
|
1170
|
+
const chars = [];
|
|
1171
|
+
for (const ch of value) {
|
|
1172
|
+
const code = ch.charCodeAt(0);
|
|
1173
|
+
if (code >= 0 && code <= 31 || code === 127 || code >= 128 && code <= 159 || ch === "[" || ch === "]") {
|
|
1174
|
+
continue;
|
|
1175
|
+
}
|
|
1176
|
+
chars.push(ch);
|
|
1177
|
+
}
|
|
1178
|
+
return chars.join("");
|
|
1179
|
+
}
|
|
1180
|
+
function sanitizeReplyDirectiveId(rawReplyToId) {
|
|
1181
|
+
const trimmed = rawReplyToId?.trim();
|
|
1182
|
+
if (!trimmed) {
|
|
1183
|
+
return void 0;
|
|
1184
|
+
}
|
|
1185
|
+
const sanitized = stripUnsafeReplyDirectiveChars(trimmed).trim();
|
|
1186
|
+
if (!sanitized) {
|
|
1187
|
+
return void 0;
|
|
1188
|
+
}
|
|
1189
|
+
const chars = Array.from(sanitized);
|
|
1190
|
+
if (chars.length > MAX_REPLY_DIRECTIVE_ID_LENGTH) {
|
|
1191
|
+
return chars.slice(0, MAX_REPLY_DIRECTIVE_ID_LENGTH).join("");
|
|
1192
|
+
}
|
|
1193
|
+
return sanitized;
|
|
1194
|
+
}
|
|
1195
|
+
function parseInlineDirectives(text, options = {}) {
|
|
1196
|
+
const { currentMessageId, stripAudioTag = true, stripReplyTags = true } = options;
|
|
1197
|
+
if (!text) {
|
|
1198
|
+
return {
|
|
1199
|
+
text: "",
|
|
1200
|
+
audioAsVoice: false,
|
|
1201
|
+
replyToCurrent: false,
|
|
1202
|
+
hasAudioTag: false,
|
|
1203
|
+
hasReplyTag: false
|
|
1204
|
+
};
|
|
1205
|
+
}
|
|
1206
|
+
if (!text.includes("[[")) {
|
|
1207
|
+
return {
|
|
1208
|
+
text: normalizeDirectiveWhitespace(text, resolveCodeRegions(text)),
|
|
1209
|
+
audioAsVoice: false,
|
|
1210
|
+
replyToCurrent: false,
|
|
1211
|
+
hasAudioTag: false,
|
|
1212
|
+
hasReplyTag: false
|
|
1213
|
+
};
|
|
1214
|
+
}
|
|
1215
|
+
const codeRegions = resolveCodeRegions(text);
|
|
1216
|
+
let cleaned = text;
|
|
1217
|
+
let audioAsVoice = false;
|
|
1218
|
+
let hasAudioTag = false;
|
|
1219
|
+
let hasReplyTag = false;
|
|
1220
|
+
let sawCurrent = false;
|
|
1221
|
+
let lastExplicitId;
|
|
1222
|
+
cleaned = cleaned.replace(AUDIO_TAG_RE, (match, offset, source) => {
|
|
1223
|
+
if (isInsideCode(offset, codeRegions)) {
|
|
1224
|
+
return match;
|
|
1225
|
+
}
|
|
1226
|
+
audioAsVoice = true;
|
|
1227
|
+
hasAudioTag = true;
|
|
1228
|
+
return stripAudioTag ? replacementPreservesWordBoundary(source, offset, match.length) : match;
|
|
1229
|
+
});
|
|
1230
|
+
const codeRegionsAfterAudioStrip = resolveCodeRegions(cleaned);
|
|
1231
|
+
cleaned = cleaned.replace(
|
|
1232
|
+
REPLY_TAG_RE,
|
|
1233
|
+
(match, idRaw, offset, source) => {
|
|
1234
|
+
if (isInsideCode(offset, codeRegionsAfterAudioStrip)) {
|
|
1235
|
+
return match;
|
|
1236
|
+
}
|
|
1237
|
+
hasReplyTag = true;
|
|
1238
|
+
if (idRaw === void 0) {
|
|
1239
|
+
sawCurrent = true;
|
|
1240
|
+
} else {
|
|
1241
|
+
const id = sanitizeReplyDirectiveId(idRaw);
|
|
1242
|
+
if (id) {
|
|
1243
|
+
lastExplicitId = id;
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
return stripReplyTags ? replacementPreservesWordBoundary(source, offset, match.length) : match;
|
|
1247
|
+
}
|
|
1248
|
+
);
|
|
1249
|
+
if (!hasAudioTag && !hasReplyTag) {
|
|
1250
|
+
return {
|
|
1251
|
+
text,
|
|
1252
|
+
audioAsVoice: false,
|
|
1253
|
+
replyToCurrent: false,
|
|
1254
|
+
hasAudioTag: false,
|
|
1255
|
+
hasReplyTag: false
|
|
1256
|
+
};
|
|
1257
|
+
}
|
|
1258
|
+
cleaned = normalizeDirectiveWhitespace(cleaned, resolveCodeRegions(cleaned));
|
|
1259
|
+
const replyToId = lastExplicitId ?? (sawCurrent ? normalizeOptionalString(currentMessageId) : void 0);
|
|
1260
|
+
return {
|
|
1261
|
+
text: cleaned,
|
|
1262
|
+
audioAsVoice,
|
|
1263
|
+
replyToId,
|
|
1264
|
+
replyToExplicitId: lastExplicitId,
|
|
1265
|
+
replyToCurrent: sawCurrent,
|
|
1266
|
+
hasAudioTag,
|
|
1267
|
+
hasReplyTag
|
|
1268
|
+
};
|
|
1269
|
+
}
|
|
1040
1270
|
|
|
1041
1271
|
// src/access-control.ts
|
|
1042
1272
|
function normalizeAllowFrom(list) {
|
|
@@ -12041,7 +12271,7 @@ ${attachmentExtractedText}` : inboundBody;
|
|
|
12041
12271
|
}
|
|
12042
12272
|
const groupMembers = !isDirect ? formatGroupMembers(storePath, groupId) : void 0;
|
|
12043
12273
|
const fromLabel = isDirect ? `${senderName} (${senderId})` : `${groupName} - ${senderName}`;
|
|
12044
|
-
const body =
|
|
12274
|
+
const body = formatInboundEnvelope({
|
|
12045
12275
|
channel: "DingTalk",
|
|
12046
12276
|
from: fromLabel,
|
|
12047
12277
|
timestamp: data.createAt,
|
|
@@ -3,9 +3,9 @@ export declare class DocCreateAppendError extends Error {
|
|
|
3
3
|
readonly doc: DocInfo;
|
|
4
4
|
constructor(doc: DocInfo, cause?: unknown);
|
|
5
5
|
}
|
|
6
|
-
export declare function createDoc(config: DingTalkConfig, spaceId: string, title: string, content?: string, log?: import("openclaw/plugin-sdk/channel-
|
|
7
|
-
export declare function appendToDoc(config: DingTalkConfig, docId: string, content: string, log?: import("openclaw/plugin-sdk/channel-
|
|
6
|
+
export declare function createDoc(config: DingTalkConfig, spaceId: string, title: string, content?: string, log?: import("openclaw/plugin-sdk/channel-contract").ChannelLogSink | undefined, parentId?: string): Promise<DocInfo>;
|
|
7
|
+
export declare function appendToDoc(config: DingTalkConfig, docId: string, content: string, log?: import("openclaw/plugin-sdk/channel-contract").ChannelLogSink | undefined, index?: number): Promise<{
|
|
8
8
|
success: true;
|
|
9
9
|
}>;
|
|
10
|
-
export declare function searchDocs(config: DingTalkConfig, keyword: string, spaceId?: string, log?: import("openclaw/plugin-sdk/channel-
|
|
11
|
-
export declare function listDocs(config: DingTalkConfig, spaceId: string, parentId?: string, log?: import("openclaw/plugin-sdk/channel-
|
|
10
|
+
export declare function searchDocs(config: DingTalkConfig, keyword: string, spaceId?: string, log?: import("openclaw/plugin-sdk/channel-contract").ChannelLogSink | undefined): Promise<DocInfo[]>;
|
|
11
|
+
export declare function listDocs(config: DingTalkConfig, spaceId: string, parentId?: string, log?: import("openclaw/plugin-sdk/channel-contract").ChannelLogSink | undefined): Promise<DocInfo[]>;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inline directive parsing (local implementation).
|
|
3
|
+
*
|
|
4
|
+
* OpenClaw retired the `openclaw/plugin-sdk/text-runtime` subpath in
|
|
5
|
+
* 2026.8.x (docs/plugins/sdk-migration.md: "The August 15 compatibility
|
|
6
|
+
* subpaths ... `text-runtime` ... were retired early"). Its replacement
|
|
7
|
+
* `openclaw/plugin-sdk/text-chunking` only re-exports the directive-tag
|
|
8
|
+
* *stripping* helpers and the `InlineDirectiveParseResult` type - not
|
|
9
|
+
* `parseInlineDirectives` itself.
|
|
10
|
+
*
|
|
11
|
+
* This module reproduces the previous host behaviour inside the plugin so no
|
|
12
|
+
* removed SDK internals are imported. Code-region awareness (fenced,
|
|
13
|
+
* indented, and inline code) is sourced from the public SDK helpers
|
|
14
|
+
* `findCodeRegions` / `isInsideCode` (available since 2026.7.1-2), plus a
|
|
15
|
+
* lenient indented-block fallback matching the host whitespace normalizer so
|
|
16
|
+
* directives inside code are never treated as real voice/reply commands.
|
|
17
|
+
*
|
|
18
|
+
* The shipped 2026.7.1-2 SDK `findCodeRegions` is a simplified heuristic: it
|
|
19
|
+
* pairs backtick runs of different lengths, lets shorter fences close longer
|
|
20
|
+
* ones, treats escaped backticks as real delimiters, and pairs backticks
|
|
21
|
+
* inside indented code with prose. `findDelimiterCodeRegions` therefore layers
|
|
22
|
+
* a CommonMark-style fence/inline-code supplement over the SDK regions (union,
|
|
23
|
+
* conservative: in doubt the text stays literal) so protection holds on every
|
|
24
|
+
* supported host. Ponytail: remove the supplement once the minimum host is
|
|
25
|
+
* 2026.8+ and its SDK regions are verified to cover these cases.
|
|
26
|
+
*/
|
|
27
|
+
export type ParseInlineDirectivesOptions = {
|
|
28
|
+
currentMessageId?: string;
|
|
29
|
+
stripAudioTag?: boolean;
|
|
30
|
+
stripReplyTags?: boolean;
|
|
31
|
+
};
|
|
32
|
+
export type InlineDirectiveParseResult = {
|
|
33
|
+
text: string;
|
|
34
|
+
audioAsVoice: boolean;
|
|
35
|
+
replyToId?: string;
|
|
36
|
+
replyToExplicitId?: string;
|
|
37
|
+
replyToCurrent: boolean;
|
|
38
|
+
hasAudioTag: boolean;
|
|
39
|
+
hasReplyTag: boolean;
|
|
40
|
+
};
|
|
41
|
+
export declare function sanitizeReplyDirectiveId(rawReplyToId: string | undefined): string | undefined;
|
|
42
|
+
export declare function parseInlineDirectives(text: string | undefined, options?: ParseInlineDirectivesOptions): InlineDirectiveParseResult;
|
package/dist/src/types.d.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* - Media files and streams
|
|
9
9
|
* - Session and token management
|
|
10
10
|
*/
|
|
11
|
-
import type { ChannelAccountSnapshot as SDKChannelAccountSnapshot, ChannelGatewayContext as SDKChannelGatewayContext, ChannelLogSink as SDKChannelLogSink } from "openclaw/plugin-sdk/channel-
|
|
11
|
+
import type { ChannelAccountSnapshot as SDKChannelAccountSnapshot, ChannelGatewayContext as SDKChannelGatewayContext, ChannelLogSink as SDKChannelLogSink } from "openclaw/plugin-sdk/channel-contract";
|
|
12
12
|
import type { ChannelPlugin as SDKChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk/core";
|
|
13
13
|
import type { ChannelSetupWizard } from "openclaw/plugin-sdk/setup";
|
|
14
14
|
import type { SecretInput } from "./secret-input";
|
package/package.json
CHANGED
package/src/inbound-handler.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
+
import { formatInboundEnvelope } from "openclaw/plugin-sdk/channel-inbound";
|
|
3
4
|
import { isAbortRequestText, isBtwRequestText } from "openclaw/plugin-sdk/reply-runtime";
|
|
4
|
-
import { parseInlineDirectives } from "
|
|
5
|
+
import { parseInlineDirectives } from "./messaging/inline-directives";
|
|
5
6
|
import { normalizeAllowFrom, isSenderAllowed, resolveGroupAccess } from "./access-control";
|
|
6
7
|
import { classifyAckReactionEmoji } from "./ack-reaction-classifier";
|
|
7
8
|
import { attachNativeAckReaction } from "./ack-reaction-service";
|
|
@@ -1792,7 +1793,10 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
|
|
|
1792
1793
|
const groupMembers = !isDirect ? formatGroupMembers(storePath, groupId) : undefined;
|
|
1793
1794
|
|
|
1794
1795
|
const fromLabel = isDirect ? `${senderName} (${senderId})` : `${groupName} - ${senderName}`;
|
|
1795
|
-
|
|
1796
|
+
// `rt.channel.reply.formatInboundEnvelope` was removed from the runtime
|
|
1797
|
+
// facade in OpenClaw 2026.8.x; the helper is still exported from the
|
|
1798
|
+
// `channel-inbound` SDK subpath.
|
|
1799
|
+
const body = formatInboundEnvelope({
|
|
1796
1800
|
channel: "DingTalk",
|
|
1797
1801
|
from: fromLabel,
|
|
1798
1802
|
timestamp: data.createAt,
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inline directive parsing (local implementation).
|
|
3
|
+
*
|
|
4
|
+
* OpenClaw retired the `openclaw/plugin-sdk/text-runtime` subpath in
|
|
5
|
+
* 2026.8.x (docs/plugins/sdk-migration.md: "The August 15 compatibility
|
|
6
|
+
* subpaths ... `text-runtime` ... were retired early"). Its replacement
|
|
7
|
+
* `openclaw/plugin-sdk/text-chunking` only re-exports the directive-tag
|
|
8
|
+
* *stripping* helpers and the `InlineDirectiveParseResult` type - not
|
|
9
|
+
* `parseInlineDirectives` itself.
|
|
10
|
+
*
|
|
11
|
+
* This module reproduces the previous host behaviour inside the plugin so no
|
|
12
|
+
* removed SDK internals are imported. Code-region awareness (fenced,
|
|
13
|
+
* indented, and inline code) is sourced from the public SDK helpers
|
|
14
|
+
* `findCodeRegions` / `isInsideCode` (available since 2026.7.1-2), plus a
|
|
15
|
+
* lenient indented-block fallback matching the host whitespace normalizer so
|
|
16
|
+
* directives inside code are never treated as real voice/reply commands.
|
|
17
|
+
*
|
|
18
|
+
* The shipped 2026.7.1-2 SDK `findCodeRegions` is a simplified heuristic: it
|
|
19
|
+
* pairs backtick runs of different lengths, lets shorter fences close longer
|
|
20
|
+
* ones, treats escaped backticks as real delimiters, and pairs backticks
|
|
21
|
+
* inside indented code with prose. `findDelimiterCodeRegions` therefore layers
|
|
22
|
+
* a CommonMark-style fence/inline-code supplement over the SDK regions (union,
|
|
23
|
+
* conservative: in doubt the text stays literal) so protection holds on every
|
|
24
|
+
* supported host. Ponytail: remove the supplement once the minimum host is
|
|
25
|
+
* 2026.8+ and its SDK regions are verified to cover these cases.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { findCodeRegions, isInsideCode, type CodeRegion } from "openclaw/plugin-sdk/text-chunking";
|
|
29
|
+
|
|
30
|
+
const AUDIO_TAG_RE = /\[\[\s*audio_as_voice\s*\]\]/gi;
|
|
31
|
+
const REPLY_TAG_RE = /\[\[\s*(?:reply_to_current|reply_to\s*:\s*([^\]\n]+))\s*\]\]/gi;
|
|
32
|
+
const MAX_REPLY_DIRECTIVE_ID_LENGTH = 256;
|
|
33
|
+
const BLOCK_SENTINEL_SEED = "\uE000";
|
|
34
|
+
/** Lenient indented-code-block lines (4 spaces or tab), same rule as the host whitespace normalizer. */
|
|
35
|
+
const INDENTED_CODE_LINE_RE = /(?:^|\n)((?: {4}|\t)[^\n]*)(?:\n(?: {4}|\t)[^\n]*)*/g;
|
|
36
|
+
|
|
37
|
+
export type ParseInlineDirectivesOptions = {
|
|
38
|
+
currentMessageId?: string;
|
|
39
|
+
stripAudioTag?: boolean;
|
|
40
|
+
stripReplyTags?: boolean;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type InlineDirectiveParseResult = {
|
|
44
|
+
text: string;
|
|
45
|
+
audioAsVoice: boolean;
|
|
46
|
+
replyToId?: string;
|
|
47
|
+
replyToExplicitId?: string;
|
|
48
|
+
replyToCurrent: boolean;
|
|
49
|
+
hasAudioTag: boolean;
|
|
50
|
+
hasReplyTag: boolean;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
function normalizeOptionalString(value: unknown): string | undefined {
|
|
54
|
+
if (typeof value !== "string") {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
const trimmed = value.trim();
|
|
58
|
+
return trimmed ? trimmed : undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* CommonMark code regions (fenced / indented / inline) from the SDK, extended
|
|
63
|
+
* with a lenient indented-block fallback for lines indented by 4 spaces or a
|
|
64
|
+
* tab that follow a paragraph without a blank separator (the host normalizer
|
|
65
|
+
* protects those too).
|
|
66
|
+
*/
|
|
67
|
+
function resolveCodeRegions(text: string): CodeRegion[] {
|
|
68
|
+
const indentedRegions: CodeRegion[] = [];
|
|
69
|
+
const indentRe = new RegExp(INDENTED_CODE_LINE_RE.source, "g");
|
|
70
|
+
let match: RegExpExecArray | null;
|
|
71
|
+
while ((match = indentRe.exec(text)) !== null) {
|
|
72
|
+
const start = match.index + (match[0].charCodeAt(0) === 10 ? 1 : 0);
|
|
73
|
+
indentedRegions.push({ start, end: match.index + match[0].length });
|
|
74
|
+
}
|
|
75
|
+
const sdkRegions = findCodeRegions(text).filter(
|
|
76
|
+
(region) =>
|
|
77
|
+
(text[region.start] !== "`" || !isEscaped(text, region.start)) &&
|
|
78
|
+
!indentedRegions.some(
|
|
79
|
+
(indented) => region.start >= indented.start && region.start < indented.end,
|
|
80
|
+
),
|
|
81
|
+
);
|
|
82
|
+
const knownRegions = mergeCodeRegions([...sdkRegions, ...indentedRegions]);
|
|
83
|
+
return mergeCodeRegions([...knownRegions, ...findDelimiterCodeRegions(text, knownRegions)]);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isEscaped(text: string, offset: number): boolean {
|
|
87
|
+
let slashes = 0;
|
|
88
|
+
for (let index = offset - 1; text[index] === "\\"; index--) {
|
|
89
|
+
slashes++;
|
|
90
|
+
}
|
|
91
|
+
return slashes % 2 === 1;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function isStrictlyInsideCode(offset: number, regions: CodeRegion[]): boolean {
|
|
95
|
+
return regions.some((region) => offset > region.start && offset < region.end);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ponytail: small 2026.7.x delimiter supplement; delete when the minimum host is 2026.8+.
|
|
99
|
+
function findDelimiterCodeRegions(text: string, knownRegions: CodeRegion[]): CodeRegion[] {
|
|
100
|
+
const regions: CodeRegion[] = [];
|
|
101
|
+
let match: RegExpExecArray | null;
|
|
102
|
+
const fences = /^( {0,3})(`{3,}|~{3,})[^\n]*(?:\n|$)/gm;
|
|
103
|
+
let open: { char: string; length: number; start: number } | undefined;
|
|
104
|
+
while ((match = fences.exec(text)) !== null) {
|
|
105
|
+
const delimiter = match[2];
|
|
106
|
+
if (!open) {
|
|
107
|
+
const suffix = match[0].slice(match[1].length + delimiter.length).trim();
|
|
108
|
+
if (
|
|
109
|
+
isStrictlyInsideCode(match.index, knownRegions) ||
|
|
110
|
+
(delimiter[0] === "`" && suffix.includes("`"))
|
|
111
|
+
) {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
open = { char: delimiter[0], length: delimiter.length, start: match.index };
|
|
115
|
+
} else if (
|
|
116
|
+
delimiter[0] === open.char &&
|
|
117
|
+
delimiter.length >= open.length &&
|
|
118
|
+
match[0].slice(match[1].length + delimiter.length).trim() === ""
|
|
119
|
+
) {
|
|
120
|
+
regions.push({ start: open.start, end: fences.lastIndex });
|
|
121
|
+
open = undefined;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (open) {
|
|
125
|
+
regions.push({ start: open.start, end: text.length });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const fencedRegions = [...regions];
|
|
129
|
+
const pendingTicks = new Map<number, number>();
|
|
130
|
+
const ticks = /`+/g;
|
|
131
|
+
while ((match = ticks.exec(text)) !== null) {
|
|
132
|
+
const length = match[0].length;
|
|
133
|
+
if (
|
|
134
|
+
isEscaped(text, match.index) ||
|
|
135
|
+
isInsideCode(match.index, fencedRegions) ||
|
|
136
|
+
(pendingTicks.get(length) === undefined && isStrictlyInsideCode(match.index, knownRegions))
|
|
137
|
+
) {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const start = pendingTicks.get(length);
|
|
141
|
+
if (start === undefined) {
|
|
142
|
+
pendingTicks.set(length, match.index);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
regions.push({ start, end: ticks.lastIndex });
|
|
146
|
+
for (const [pendingLength, pendingStart] of pendingTicks) {
|
|
147
|
+
if (pendingStart >= start) {
|
|
148
|
+
pendingTicks.delete(pendingLength);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return regions;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function mergeCodeRegions(regions: CodeRegion[]): CodeRegion[] {
|
|
156
|
+
if (regions.length <= 1) {
|
|
157
|
+
return regions;
|
|
158
|
+
}
|
|
159
|
+
const sorted = [...regions].toSorted((a, b) => a.start - b.start || a.end - b.end);
|
|
160
|
+
const merged: CodeRegion[] = [];
|
|
161
|
+
for (const region of sorted) {
|
|
162
|
+
const last = merged[merged.length - 1];
|
|
163
|
+
if (last && region.start <= last.end) {
|
|
164
|
+
last.end = Math.max(last.end, region.end);
|
|
165
|
+
} else {
|
|
166
|
+
merged.push({ start: region.start, end: region.end });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return merged;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function createBlockSentinel(text: string): string {
|
|
173
|
+
let sentinel = BLOCK_SENTINEL_SEED;
|
|
174
|
+
while (text.includes(sentinel)) {
|
|
175
|
+
sentinel += BLOCK_SENTINEL_SEED;
|
|
176
|
+
}
|
|
177
|
+
return sentinel;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function normalizeDirectiveWhitespace(text: string, codeRegions: CodeRegion[] = []): string {
|
|
181
|
+
const blockSentinel = createBlockSentinel(text);
|
|
182
|
+
const blockPlaceholderRe = new RegExp(`${blockSentinel}(\\d+)${blockSentinel}`, "g");
|
|
183
|
+
const blocks: string[] = [];
|
|
184
|
+
|
|
185
|
+
let masked = "";
|
|
186
|
+
let cursor = 0;
|
|
187
|
+
for (const region of codeRegions) {
|
|
188
|
+
if (region.start < cursor) {
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
blocks.push(text.slice(region.start, region.end));
|
|
192
|
+
masked += `${text.slice(cursor, region.start)}${blockSentinel}${blocks.length - 1}${blockSentinel}`;
|
|
193
|
+
cursor = region.end;
|
|
194
|
+
}
|
|
195
|
+
masked += text.slice(cursor);
|
|
196
|
+
|
|
197
|
+
return masked
|
|
198
|
+
.replace(/\r\n/g, "\n")
|
|
199
|
+
.replace(/([^\s])[ \t]{2,}([^\s])/g, "$1 $2")
|
|
200
|
+
.replace(/^\n+/, "")
|
|
201
|
+
.replace(/^[ \t](?=\S)/, "")
|
|
202
|
+
.replace(/[ \t]+\n/g, "\n")
|
|
203
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
204
|
+
.trimEnd()
|
|
205
|
+
.replace(blockPlaceholderRe, (_full, index: string) => blocks[Number(index)] ?? "");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function replacementPreservesWordBoundary(source: string, offset: number, length: number): string {
|
|
209
|
+
const before = source[offset - 1];
|
|
210
|
+
const after = source[offset + length];
|
|
211
|
+
return before && after && !/\s/u.test(before) && !/\s/u.test(after) ? " " : "";
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function stripUnsafeReplyDirectiveChars(value: string): string {
|
|
215
|
+
const chars: string[] = [];
|
|
216
|
+
for (const ch of value) {
|
|
217
|
+
const code = ch.charCodeAt(0);
|
|
218
|
+
if (
|
|
219
|
+
(code >= 0 && code <= 31) ||
|
|
220
|
+
code === 127 ||
|
|
221
|
+
(code >= 128 && code <= 159) ||
|
|
222
|
+
ch === "[" ||
|
|
223
|
+
ch === "]"
|
|
224
|
+
) {
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
chars.push(ch);
|
|
228
|
+
}
|
|
229
|
+
return chars.join("");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function sanitizeReplyDirectiveId(rawReplyToId: string | undefined): string | undefined {
|
|
233
|
+
const trimmed = rawReplyToId?.trim();
|
|
234
|
+
if (!trimmed) {
|
|
235
|
+
return undefined;
|
|
236
|
+
}
|
|
237
|
+
const sanitized = stripUnsafeReplyDirectiveChars(trimmed).trim();
|
|
238
|
+
if (!sanitized) {
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
const chars = Array.from(sanitized);
|
|
242
|
+
if (chars.length > MAX_REPLY_DIRECTIVE_ID_LENGTH) {
|
|
243
|
+
return chars.slice(0, MAX_REPLY_DIRECTIVE_ID_LENGTH).join("");
|
|
244
|
+
}
|
|
245
|
+
return sanitized;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function parseInlineDirectives(
|
|
249
|
+
text: string | undefined,
|
|
250
|
+
options: ParseInlineDirectivesOptions = {},
|
|
251
|
+
): InlineDirectiveParseResult {
|
|
252
|
+
const { currentMessageId, stripAudioTag = true, stripReplyTags = true } = options;
|
|
253
|
+
|
|
254
|
+
if (!text) {
|
|
255
|
+
return {
|
|
256
|
+
text: "",
|
|
257
|
+
audioAsVoice: false,
|
|
258
|
+
replyToCurrent: false,
|
|
259
|
+
hasAudioTag: false,
|
|
260
|
+
hasReplyTag: false,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (!text.includes("[[")) {
|
|
265
|
+
return {
|
|
266
|
+
text: normalizeDirectiveWhitespace(text, resolveCodeRegions(text)),
|
|
267
|
+
audioAsVoice: false,
|
|
268
|
+
replyToCurrent: false,
|
|
269
|
+
hasAudioTag: false,
|
|
270
|
+
hasReplyTag: false,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Directives inside code regions are literal text: never voice/reply semantics.
|
|
275
|
+
// Stripping a tag shortens the string, so every pass re-derives regions from
|
|
276
|
+
// the text it actually runs against; offsets from an earlier pass are stale
|
|
277
|
+
// once a preceding tag has been removed.
|
|
278
|
+
const codeRegions = resolveCodeRegions(text);
|
|
279
|
+
let cleaned = text;
|
|
280
|
+
let audioAsVoice = false;
|
|
281
|
+
let hasAudioTag = false;
|
|
282
|
+
let hasReplyTag = false;
|
|
283
|
+
let sawCurrent = false;
|
|
284
|
+
let lastExplicitId: string | undefined;
|
|
285
|
+
|
|
286
|
+
cleaned = cleaned.replace(AUDIO_TAG_RE, (match: string, offset: number, source: string) => {
|
|
287
|
+
if (isInsideCode(offset, codeRegions)) {
|
|
288
|
+
return match;
|
|
289
|
+
}
|
|
290
|
+
audioAsVoice = true;
|
|
291
|
+
hasAudioTag = true;
|
|
292
|
+
return stripAudioTag ? replacementPreservesWordBoundary(source, offset, match.length) : match;
|
|
293
|
+
});
|
|
294
|
+
const codeRegionsAfterAudioStrip = resolveCodeRegions(cleaned);
|
|
295
|
+
|
|
296
|
+
cleaned = cleaned.replace(
|
|
297
|
+
REPLY_TAG_RE,
|
|
298
|
+
(match: string, idRaw: string | undefined, offset: number, source: string) => {
|
|
299
|
+
if (isInsideCode(offset, codeRegionsAfterAudioStrip)) {
|
|
300
|
+
return match;
|
|
301
|
+
}
|
|
302
|
+
hasReplyTag = true;
|
|
303
|
+
if (idRaw === undefined) {
|
|
304
|
+
sawCurrent = true;
|
|
305
|
+
} else {
|
|
306
|
+
const id = sanitizeReplyDirectiveId(idRaw);
|
|
307
|
+
if (id) {
|
|
308
|
+
lastExplicitId = id;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return stripReplyTags
|
|
312
|
+
? replacementPreservesWordBoundary(source, offset, match.length)
|
|
313
|
+
: match;
|
|
314
|
+
},
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
// Early return when `[[...]]` matched no known directive: keep the original
|
|
318
|
+
// text untouched instead of running whitespace normalization.
|
|
319
|
+
if (!hasAudioTag && !hasReplyTag) {
|
|
320
|
+
return {
|
|
321
|
+
text,
|
|
322
|
+
audioAsVoice: false,
|
|
323
|
+
replyToCurrent: false,
|
|
324
|
+
hasAudioTag: false,
|
|
325
|
+
hasReplyTag: false,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
cleaned = normalizeDirectiveWhitespace(cleaned, resolveCodeRegions(cleaned));
|
|
330
|
+
const replyToId =
|
|
331
|
+
lastExplicitId ?? (sawCurrent ? normalizeOptionalString(currentMessageId) : undefined);
|
|
332
|
+
|
|
333
|
+
return {
|
|
334
|
+
text: cleaned,
|
|
335
|
+
audioAsVoice,
|
|
336
|
+
replyToId,
|
|
337
|
+
replyToExplicitId: lastExplicitId,
|
|
338
|
+
replyToCurrent: sawCurrent,
|
|
339
|
+
hasAudioTag,
|
|
340
|
+
hasReplyTag,
|
|
341
|
+
};
|
|
342
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -13,7 +13,7 @@ import type {
|
|
|
13
13
|
ChannelAccountSnapshot as SDKChannelAccountSnapshot,
|
|
14
14
|
ChannelGatewayContext as SDKChannelGatewayContext,
|
|
15
15
|
ChannelLogSink as SDKChannelLogSink,
|
|
16
|
-
} from "openclaw/plugin-sdk/channel-
|
|
16
|
+
} from "openclaw/plugin-sdk/channel-contract";
|
|
17
17
|
import type { ChannelPlugin as SDKChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk/core";
|
|
18
18
|
import type { ChannelSetupWizard } from "openclaw/plugin-sdk/setup";
|
|
19
19
|
import type { SecretInput } from "./secret-input";
|