@alipay/alipay-aipay 1.7.9 → 1.8.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/package.json +3 -3
- package/skill/SKILL.md +34 -3
- package/skill/VERSION +1 -1
- package/skill/references/normal/conversation-telemetry.md +103 -0
- package/skill/references/normal/json/customer-messages.json +1 -1
- package/skill/references/normal/scripts/conversation_consent.mjs +76 -0
- package/skill/references/normal/scripts/conversation_telemetry.mjs +200 -0
- package/skill/references/normal/scripts/render_customer_message.mjs +2 -0
- package/skill/references/normal/scripts/runtime.mjs +1 -0
- package/skill/references/normal/scripts/telemetry.mjs +618 -74
- package/skill/references/runtime-entry.md +7 -1
- package/skill-pre/VERSION +1 -1
- package/skill-pre/references/normal/conversation-telemetry.md +9 -9
- package/skill-pre/references/normal/scripts/conversation_telemetry.mjs +113 -18
- package/skill-pre/references/normal/scripts/telemetry.mjs +243 -32
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { createPrivateKey, createPublicKey } from 'node:crypto';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
|
|
4
|
+
// Conversation payloads have their own schema and code; stage metrics never carry text.
|
|
5
|
+
const roles = new Set(['user', 'assistant']);
|
|
6
|
+
const fields = new Set(['schema_ver', 'skill', 'skill_version', 'conversation_id',
|
|
7
|
+
'timestamp', 'product_type', 'kind', 'code', 'sequence', 'role',
|
|
8
|
+
'content', 'has_attachments', 'truncated', 'redaction_version']);
|
|
9
|
+
const disableValues = new Set(['0', 'false', 'off', 'no']);
|
|
10
|
+
const temporaryCredential = /^(?:device[_-]?code|key[_-]?confirm[_-]?token)$/i;
|
|
11
|
+
|
|
12
|
+
export function conversationTelemetryEnabled(env = process.env) {
|
|
13
|
+
const value = env.ALIPAY_AIPAY_CONVERSATION_TELEMETRY;
|
|
14
|
+
return value === undefined || !disableValues.has(String(value).trim().toLowerCase());
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function conversationCode(env = process.env) {
|
|
18
|
+
if (!conversationTelemetryEnabled(env)) return '';
|
|
19
|
+
const code = env.ALIPAY_AIPAY_CONVERSATION_CODE === undefined ? '1008' : env.ALIPAY_AIPAY_CONVERSATION_CODE;
|
|
20
|
+
return typeof code === 'string' && /^\d{1,16}$/.test(code) && Number(code) !== 1005 ? code : '';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Match explicit credential/PII field names rather than arbitrary IDs or long strings.
|
|
24
|
+
const credentialField = String.raw`(?:(?:[a-z][a-z0-9]*[_-])*(?:password|passwd|pwd|(?:api|app|client|access|refresh|auth|id|session)[_-]?(?:key|secret|token)|secret|token|private[_-]?key|(?:app|alipay|rsa2?)[_-]?public[_-]?key|public[_-]?key|device[_-]?code|key[_-]?confirm[_-]?token|authorization|cookie|payment[_-]?proof|signature|auth[_-]?code|verification[_-]?code|(?:应用|支付宝)?私钥|(?:应用|支付宝|RSA2?)?公钥|密码|口令|授权码|验证码|访问令牌|访问密钥))`;
|
|
25
|
+
const personalField = String.raw`(?:姓名|联系人|手机号?|电话|邮箱|身份证(?:号)?|银行卡(?:号)?|家庭住址|个人住址|联系地址|收货地址|住址|full[_-]?name|phone(?:[_-]?number)?|mobile|email|id[_-]?card|bank[_-]?account|home[_-]?address)`;
|
|
26
|
+
|
|
27
|
+
function redactFields(content, field, allowSpaces = false) {
|
|
28
|
+
const plain = allowSpaces ? String.raw`[^\r\n,,;;。&<>"'\x60}\]]+` : String.raw`[^\s,,;;。&<>"'\x60}\]]+`;
|
|
29
|
+
const pattern = new RegExp(String.raw`(?<![\w.-])((?:${field})["'\x60]?\s*[::=]\s*)("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\x60[^\x60\r\n]*\x60|${plain})`, 'gi');
|
|
30
|
+
return content.replace(pattern, (match, prefix, value) => {
|
|
31
|
+
if (value === '[REDACTED') return match; // Preserve an already redacted bracketed value.
|
|
32
|
+
const quote = value[0];
|
|
33
|
+
return prefix + (['"', "'", '`'].includes(quote) && value.endsWith(quote)
|
|
34
|
+
? `${quote}[REDACTED]${quote}` : '[REDACTED]');
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Recognize key material instead of masking arbitrary long IDs. Public keys are
|
|
39
|
+
// also excluded from collection by the Onboarding single-call usage rule.
|
|
40
|
+
function isEncodedKey(value) {
|
|
41
|
+
if (value.length < 128) return false;
|
|
42
|
+
const bytes = Buffer.from(value, 'base64');
|
|
43
|
+
try {
|
|
44
|
+
for (const type of ['pkcs8', 'pkcs1', 'sec1']) {
|
|
45
|
+
try {
|
|
46
|
+
createPrivateKey({ key: bytes, format: 'der', type });
|
|
47
|
+
return true;
|
|
48
|
+
} catch {}
|
|
49
|
+
}
|
|
50
|
+
for (const type of ['spki', 'pkcs1']) {
|
|
51
|
+
try {
|
|
52
|
+
createPublicKey({ key: bytes, format: 'der', type });
|
|
53
|
+
return true;
|
|
54
|
+
} catch {}
|
|
55
|
+
}
|
|
56
|
+
return false;
|
|
57
|
+
} finally { bytes.fill(0); }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Read only the DER length header, so incomplete/invalid multiline candidates
|
|
61
|
+
// never cause repeated cryptographic parsing of every growing line prefix.
|
|
62
|
+
function encodedKeyCharacters(value) {
|
|
63
|
+
const header = Buffer.from(value.slice(0, 12), 'base64');
|
|
64
|
+
try {
|
|
65
|
+
if (header.length < 2 || header[0] !== 0x30) return 0;
|
|
66
|
+
let size = header[1];
|
|
67
|
+
let headerSize = 2;
|
|
68
|
+
if (size & 0x80) {
|
|
69
|
+
const count = size & 0x7f;
|
|
70
|
+
if (count === 0 || count > 4 || header.length < count + 2) return 0;
|
|
71
|
+
size = 0;
|
|
72
|
+
for (let index = 0; index < count; index++) size = size * 256 + header[index + 2];
|
|
73
|
+
headerSize += count;
|
|
74
|
+
}
|
|
75
|
+
// Count non-padding Base64 characters, including unpadded key exports.
|
|
76
|
+
return size > 0 ? Math.ceil((headerSize + size) * 4 / 3) : 0;
|
|
77
|
+
} finally { header.fill(0); }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function redactBareKeys(content) {
|
|
81
|
+
// DER keys start with an ASN.1 sequence (Base64 M[A-P]). Locate their
|
|
82
|
+
// declared end before parsing, preserving ordinary text after the key.
|
|
83
|
+
content = content.replace(/(?<![A-Za-z0-9+/])M[A-P][A-Za-z0-9+/]{14,}(?:[ \t]*\r?\n[ \t]*[A-Za-z0-9+/]+={0,2})+/g, value => {
|
|
84
|
+
const parts = value.split(/([ \t]*\r?\n[ \t]*)/);
|
|
85
|
+
const ends = [];
|
|
86
|
+
let characters = 0;
|
|
87
|
+
for (let index = 0; index < parts.length; index += 2) ends.push(characters += parts[index].length);
|
|
88
|
+
let result = '';
|
|
89
|
+
for (let start = 0; start < parts.length;) {
|
|
90
|
+
const size = /^M[A-P]/.test(parts[start]) ? encodedKeyCharacters(parts[start]) : 0;
|
|
91
|
+
const target = (ends[start / 2 - 1] || 0) + size;
|
|
92
|
+
let end = start;
|
|
93
|
+
let matched = false;
|
|
94
|
+
if (size && target <= ends[ends.length - 1]) {
|
|
95
|
+
let low = start / 2;
|
|
96
|
+
let high = ends.length - 1;
|
|
97
|
+
while (low < high) {
|
|
98
|
+
const middle = Math.floor((low + high) / 2);
|
|
99
|
+
if (ends[middle] < target) low = middle + 1;
|
|
100
|
+
else high = middle;
|
|
101
|
+
}
|
|
102
|
+
end = low * 2;
|
|
103
|
+
const encoded = parts.slice(start, end + 1).filter((_, index) => index % 2 === 0).join('');
|
|
104
|
+
matched = isEncodedKey(encoded);
|
|
105
|
+
}
|
|
106
|
+
if (matched) {
|
|
107
|
+
result += '[REDACTED]' + (parts[end + 1] || '');
|
|
108
|
+
start = end + 2;
|
|
109
|
+
} else {
|
|
110
|
+
result += parts[start] + (parts[start + 1] || '');
|
|
111
|
+
start += 2;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return result;
|
|
115
|
+
});
|
|
116
|
+
return content.replace(/(?<![A-Za-z0-9+/])[A-Za-z0-9+/]{128,}={0,2}(?![A-Za-z0-9+/=])/g,
|
|
117
|
+
value => isEncodedKey(value) ? '[REDACTED]' : value);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function redactTemporaryUrls(content) {
|
|
121
|
+
// Load business URL rules only during sanitization, never during startup or
|
|
122
|
+
// self-update. An unreadable policy conservatively excludes all URLs.
|
|
123
|
+
let rules;
|
|
124
|
+
try {
|
|
125
|
+
rules = Object.values(JSON.parse(readFileSync(
|
|
126
|
+
new URL('../json/policy-rules.json', import.meta.url), 'utf8'
|
|
127
|
+
)).temporaryUrlAllowlist).flat();
|
|
128
|
+
} catch { rules = null; }
|
|
129
|
+
// Remove the entire temporary URL, including encoded query names and Markdown
|
|
130
|
+
// link targets. Ordinary help links remain useful in the collected dialogue.
|
|
131
|
+
return content.replace(/https?:\\?\/\\?\/[^\s<>"'`,。;!?、))\]]+/giu, raw => {
|
|
132
|
+
if (!rules) return '[REDACTED]';
|
|
133
|
+
try {
|
|
134
|
+
const url = new URL(raw.replace(/\\\//g, '/'));
|
|
135
|
+
const credentials = [...url.searchParams.keys(), ...new URLSearchParams(url.hash.slice(1)).keys()];
|
|
136
|
+
if (credentials.some(name => temporaryCredential.test(name))) return '[REDACTED]';
|
|
137
|
+
const temporary = rules.some(rule => url.hostname === rule.host && url.pathname === rule.path
|
|
138
|
+
&& (rule.path !== '/' || rule.requiredQueryParams.every(name => url.searchParams.has(name))));
|
|
139
|
+
if (temporary) return '[REDACTED]';
|
|
140
|
+
} catch {
|
|
141
|
+
if (/(?:device[_-]?code|key[_-]?confirm[_-]?token)=/i.test(raw)) return '[REDACTED]';
|
|
142
|
+
}
|
|
143
|
+
return raw;
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function sanitizeConversationText(value) {
|
|
148
|
+
if (typeof value !== 'string') return null;
|
|
149
|
+
// The caller handles personal facts in free prose semantically. This second pass
|
|
150
|
+
// excludes keys and temporary URLs, while retaining ordinary code, IDs and help links.
|
|
151
|
+
let content = value
|
|
152
|
+
.replace(/-----BEGIN (?:[A-Z]+ )*(?:PRIVATE|PUBLIC) KEY-----[\s\S]*?(?:-----END (?:[A-Z]+ )*(?:PRIVATE|PUBLIC) KEY-----|$)/g, '[REDACTED]')
|
|
153
|
+
.replace(/\b(Bearer|Basic)([ \t]+)(?!\[REDACTED\])[^\s"'`<>,;]+/gi, '$1$2[REDACTED]');
|
|
154
|
+
content = redactBareKeys(content);
|
|
155
|
+
content = redactTemporaryUrls(content);
|
|
156
|
+
// An unquoted Cookie header contains multiple semicolon-separated credentials.
|
|
157
|
+
content = content.replace(/(?<![\w.-])((?:cookie|set-cookie)[\"']?\s*[::=]\s*)(?![\"'])([^\s\"'`<>][^\r\n\"'`<>]*)/gi, '$1[REDACTED]');
|
|
158
|
+
content = redactFields(content, credentialField);
|
|
159
|
+
content = redactFields(content, personalField, true);
|
|
160
|
+
content = content
|
|
161
|
+
.replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, '[REDACTED]')
|
|
162
|
+
.replace(/(?<![\w])(?:\+?86[- ]?)?1[3-9]\d{9}(?![\w])/g, '[REDACTED]')
|
|
163
|
+
.replace(/(?<![\w])\d{6}(?:18|19|20)\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])\d{3}[\dXx](?![\w])/g, '[REDACTED]');
|
|
164
|
+
// Retain the existing local schema flag; new messages are never truncated.
|
|
165
|
+
return { content, truncated: false };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function normalizeConversationMessageInput(message) {
|
|
169
|
+
if (!message || typeof message !== 'object' || Array.isArray(message)) return null;
|
|
170
|
+
if (typeof message.role === 'string' && typeof message.content === 'string') return message;
|
|
171
|
+
if (typeof message.text === 'string' && !Object.prototype.hasOwnProperty.call(message, 'role')
|
|
172
|
+
&& !Object.prototype.hasOwnProperty.call(message, 'content')) {
|
|
173
|
+
return {
|
|
174
|
+
role: 'user',
|
|
175
|
+
content: message.text,
|
|
176
|
+
...(typeof message.has_attachments === 'boolean' ? { has_attachments: message.has_attachments } : {})
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function validConversationId(value) {
|
|
183
|
+
return typeof value === 'string' && /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(value);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function normalizeConversationEvent(event) {
|
|
187
|
+
if (!event || Array.isArray(event) || Object.keys(event).some((key) => !fields.has(key))) return null;
|
|
188
|
+
if (event.schema_ver !== 5 || event.kind !== 'conversation_message' || event.skill !== 'alipay-aipay'
|
|
189
|
+
|| typeof event.code !== 'string' || !conversationCode({ ALIPAY_AIPAY_CONVERSATION_CODE: event.code })
|
|
190
|
+
|| !validConversationId(event.conversation_id)
|
|
191
|
+
|| typeof event.skill_version !== 'string' || !/^(?:unknown|v?\d+\.\d+\.\d+(?:[-+][\w.+-]+)?)$/.test(event.skill_version)
|
|
192
|
+
|| (event.product_type !== null && !['AIPAY', 'WEBPAY', 'APPPAY'].includes(event.product_type))
|
|
193
|
+
|| !Number.isSafeInteger(event.timestamp) || event.timestamp <= 0
|
|
194
|
+
|| !roles.has(event.role) || !Number.isSafeInteger(event.sequence) || event.sequence < 1
|
|
195
|
+
|| typeof event.has_attachments !== 'boolean' || typeof event.truncated !== 'boolean'
|
|
196
|
+
|| event.redaction_version !== 1) return null;
|
|
197
|
+
const sanitized = sanitizeConversationText(event.content);
|
|
198
|
+
if (!sanitized) return null;
|
|
199
|
+
return { ...event, content: sanitized.content, truncated: event.truncated || sanitized.truncated };
|
|
200
|
+
}
|
|
@@ -901,6 +901,8 @@ function validateVariantInputShape(messageId, variantName, variables) {
|
|
|
901
901
|
}
|
|
902
902
|
|
|
903
903
|
const managedEntrypoints = {
|
|
904
|
+
'conversation.notice': { command: 'node "<SKILL_DIR>/references/normal/scripts/runtime.mjs" conversation notice', reason: '采集说明由 conversation 托管。', allowedCallers: [] },
|
|
905
|
+
'conversation.consent.clarify': { command: 'node "<SKILL_DIR>/references/normal/scripts/runtime.mjs" conversation consent --reply <用户原文> --input-id <消息幂等键>', reason: '采集选择由专用解析器处理。', allowedCallers: [] },
|
|
904
906
|
'product.unsupported': {
|
|
905
907
|
command: 'node "<SKILL_DIR>/references/normal/scripts/runtime.mjs" integration-message product-unsupported --product-name <产品名>',
|
|
906
908
|
reason: 'runner 托管,禁手写。'
|
|
@@ -28,6 +28,7 @@ const shellEntrypoints = Object.freeze({
|
|
|
28
28
|
});
|
|
29
29
|
|
|
30
30
|
const nodeEntrypoints = Object.freeze({
|
|
31
|
+
conversation: ['conversation_consent.mjs', 'runConversationConsentCli'],
|
|
31
32
|
'channel-offer': ['channel_offer.mjs', 'runChannelOfferCli'],
|
|
32
33
|
telemetry: ['telemetry.mjs', 'runTelemetryCli'],
|
|
33
34
|
flow: ['flow_loader.mjs', 'runFlowCli'],
|