feishu-user-plugin 1.3.7 → 1.3.9
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/.claude-plugin/plugin.json +13 -3
- package/CHANGELOG.md +87 -0
- package/README.md +20 -4
- package/package.json +10 -6
- package/proto/lark.proto +10 -0
- package/scripts/capture-feishu-protobuf.js +86 -0
- package/scripts/check-changelog.js +31 -0
- package/scripts/check-docs-sync.js +41 -0
- package/scripts/check-tool-count.js +32 -7
- package/scripts/decode-feishu-protobuf.js +115 -0
- package/scripts/explore-card-protobuf.js +144 -0
- package/scripts/explore-image-minimize.js +163 -0
- package/scripts/generate-release-artifacts.js +318 -0
- package/scripts/probe-feishu-docx.js +203 -0
- package/scripts/sync-server-json.js +71 -0
- package/scripts/sync-team-skills.sh +109 -7
- package/scripts/test-wiki-attach-fallback.js +71 -0
- package/scripts/test-ws-events.js +84 -0
- package/skills/feishu-user-plugin/SKILL.md +77 -5
- package/skills/feishu-user-plugin/references/CLAUDE.md +208 -297
- package/src/auth/cookie.js +30 -0
- package/src/auth/credentials.js +85 -0
- package/src/auth/profile-router.js +248 -0
- package/src/auth/uat.js +231 -0
- package/src/cli.js +86 -42
- package/src/clients/official/base.js +12 -248
- package/src/clients/user.js +19 -31
- package/src/config.js +13 -8
- package/src/events/cursor.js +103 -0
- package/src/events/event-buffer.js +103 -0
- package/src/events/event-log.js +151 -0
- package/src/events/index.js +12 -0
- package/src/events/lockfile.js +126 -0
- package/src/events/owner.js +73 -0
- package/src/events/ws-server.js +156 -0
- package/src/oauth.js +48 -7
- package/src/resolver.js +10 -0
- package/src/server.js +285 -3
- package/src/setup.js +100 -11
- package/src/test-all.js +12 -9
- package/src/test-events-cursor.js +56 -0
- package/src/test-events-lockfile.js +36 -0
- package/src/test-events-log.js +67 -0
- package/src/test-events-owner.js +64 -0
- package/src/test-fixtures/doc-blocks/sample-1.json +1256 -0
- package/src/test-read-doc-markdown.js +61 -0
- package/src/test-switch-profile.js +171 -0
- package/src/tools/_registry.js +1 -0
- package/src/tools/diagnostics.js +10 -3
- package/src/tools/docs.js +93 -3
- package/src/tools/events.js +174 -0
- package/src/tools/messaging-bot.js +2 -3
- package/src/tools/messaging-user.js +23 -14
- package/src/tools/profile.js +43 -7
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// CARD (type=14) protobuf field exploration. Probe field numbers + types.
|
|
4
|
+
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const protobuf = require('protobufjs');
|
|
8
|
+
|
|
9
|
+
const claudeCfg = JSON.parse(fs.readFileSync(path.join(require('os').homedir(), '.claude.json'), 'utf8'));
|
|
10
|
+
const env = claudeCfg.mcpServers?.['feishu-user-plugin']?.env || {};
|
|
11
|
+
process.env.LARK_COOKIE = env.LARK_COOKIE;
|
|
12
|
+
process.env.LARK_APP_ID = env.LARK_APP_ID;
|
|
13
|
+
process.env.LARK_APP_SECRET = env.LARK_APP_SECRET;
|
|
14
|
+
|
|
15
|
+
const PLUGIN_ROOT = path.join(__dirname, '..');
|
|
16
|
+
const { LarkUserClient } = require(path.join(PLUGIN_ROOT, 'src/clients/user'));
|
|
17
|
+
const { generateCid, generateRequestId } = require(path.join(PLUGIN_ROOT, 'src/utils'));
|
|
18
|
+
|
|
19
|
+
const TEST_GROUP_NAME = '飞书plugin测试群';
|
|
20
|
+
|
|
21
|
+
const errProto = protobuf.parse(`
|
|
22
|
+
syntax = "proto3";
|
|
23
|
+
message ErrorResponse {
|
|
24
|
+
optional string message = 1;
|
|
25
|
+
optional int32 code = 2;
|
|
26
|
+
optional int32 subCode = 3;
|
|
27
|
+
optional string detail = 4;
|
|
28
|
+
optional string trace = 5;
|
|
29
|
+
optional string requestId = 6;
|
|
30
|
+
}
|
|
31
|
+
`).root;
|
|
32
|
+
const ErrorResponse = errProto.lookupType('ErrorResponse');
|
|
33
|
+
|
|
34
|
+
async function fetchProto(url, opts) {
|
|
35
|
+
const u = new URL(url);
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
const req = require('node:https').request({
|
|
38
|
+
hostname: u.hostname,
|
|
39
|
+
port: u.port || 443,
|
|
40
|
+
path: u.pathname + u.search,
|
|
41
|
+
method: opts.method || 'GET',
|
|
42
|
+
headers: opts.headers || {},
|
|
43
|
+
}, (res) => {
|
|
44
|
+
const chunks = [];
|
|
45
|
+
res.on('data', (c) => chunks.push(c));
|
|
46
|
+
res.on('end', () => resolve({ status: res.statusCode, body: Buffer.concat(chunks) }));
|
|
47
|
+
});
|
|
48
|
+
req.on('error', reject);
|
|
49
|
+
if (opts.body) req.write(opts.body);
|
|
50
|
+
req.end();
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function encodeVarint(n) {
|
|
55
|
+
const bytes = [];
|
|
56
|
+
while (n >= 0x80) { bytes.push((n & 0x7f) | 0x80); n = Math.floor(n / 128); }
|
|
57
|
+
bytes.push(n & 0x7f);
|
|
58
|
+
return Buffer.from(bytes);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function encodeWireField(num, type, val) {
|
|
62
|
+
if (type === 'varint') return Buffer.concat([Buffer.from([(num << 3) | 0]), encodeVarint(val)]);
|
|
63
|
+
if (type === 'string' || type === 'bytes') {
|
|
64
|
+
const v = type === 'string' ? Buffer.from(val, 'utf8') : val;
|
|
65
|
+
const tag = (num << 3) | 2;
|
|
66
|
+
if (num < 16) return Buffer.concat([Buffer.from([tag]), encodeVarint(v.length), v]);
|
|
67
|
+
// For field numbers > 15, tag is multi-byte varint
|
|
68
|
+
return Buffer.concat([encodeVarint(tag), encodeVarint(v.length), v]);
|
|
69
|
+
}
|
|
70
|
+
throw new Error('unknown type');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function trySendRaw(userClient, chatId, msgType, contentBytes, label) {
|
|
74
|
+
const proto = userClient.proto;
|
|
75
|
+
const PutMessageRequest = proto.lookupType('PutMessageRequest');
|
|
76
|
+
const Packet = proto.lookupType('Packet');
|
|
77
|
+
|
|
78
|
+
const restReq = PutMessageRequest.encode(PutMessageRequest.create({
|
|
79
|
+
type: msgType, chatId, cid: generateCid(), isNotified: true, version: 1,
|
|
80
|
+
})).finish();
|
|
81
|
+
const contentField = Buffer.concat([
|
|
82
|
+
Buffer.from([(2 << 3) | 2]),
|
|
83
|
+
encodeVarint(contentBytes.length),
|
|
84
|
+
contentBytes,
|
|
85
|
+
]);
|
|
86
|
+
const reqBuf = Buffer.concat([contentField, restReq]);
|
|
87
|
+
|
|
88
|
+
const packetBuf = Packet.encode(Packet.create({
|
|
89
|
+
payloadType: 1,
|
|
90
|
+
cmd: 5,
|
|
91
|
+
cid: generateRequestId(),
|
|
92
|
+
payload: reqBuf,
|
|
93
|
+
})).finish();
|
|
94
|
+
|
|
95
|
+
const res = await fetchProto('https://internal-api-lark-api.feishu.cn/im/gateway/', {
|
|
96
|
+
method: 'POST',
|
|
97
|
+
headers: userClient._protoHeaders(5, '5.7.0'),
|
|
98
|
+
body: packetBuf,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
let parsedErr = null;
|
|
102
|
+
try { parsedErr = ErrorResponse.toObject(ErrorResponse.decode(res.body)); } catch (_) {}
|
|
103
|
+
|
|
104
|
+
const status = res.status === 200 ? '✓ OK' : `✗ ${res.status}`;
|
|
105
|
+
const errMsg = parsedErr?.message?.slice(0, 120) || '';
|
|
106
|
+
console.log(`${status} [${label}] ${errMsg}`);
|
|
107
|
+
return { ok: res.status === 200, status: res.status, err: errMsg };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
(async () => {
|
|
111
|
+
const userClient = new LarkUserClient(process.env.LARK_COOKIE);
|
|
112
|
+
await userClient.init();
|
|
113
|
+
const sr = await userClient.search(TEST_GROUP_NAME);
|
|
114
|
+
const chatId = sr.find(x => x.type === 'group' && x.title.includes(TEST_GROUP_NAME)).id;
|
|
115
|
+
console.log('chatId:', chatId);
|
|
116
|
+
|
|
117
|
+
const card = JSON.stringify({
|
|
118
|
+
config: { wide_screen_mode: true },
|
|
119
|
+
header: { title: { tag: 'plain_text', content: '[explore-card] please ignore' } },
|
|
120
|
+
elements: [{ tag: 'div', text: { tag: 'lark_md', content: 'Cookie protobuf test card' } }],
|
|
121
|
+
});
|
|
122
|
+
const cardBuf = Buffer.from(card, 'utf8');
|
|
123
|
+
console.log('card json size:', cardBuf.length);
|
|
124
|
+
|
|
125
|
+
console.log('\n=== Phase A: try type=14 with each unused field number ===');
|
|
126
|
+
// Known used: 1(text), 2(imageKey), 3(title), 6(fileKey), 7(audioKey), 11(fileName), 14(richText), 24(stickerSetId), 25(stickerId)
|
|
127
|
+
// Unused (potential card field): 4, 5, 8, 9, 10, 12, 13, 15, 16, 17, 18, 19, 20, 21, 22, 23
|
|
128
|
+
const candidates = [4, 5, 8, 9, 10, 12, 13, 15, 16, 17, 18, 19, 20, 21, 22, 23];
|
|
129
|
+
for (const fieldNum of candidates) {
|
|
130
|
+
// Try as string (JSON)
|
|
131
|
+
const ckS = encodeWireField(fieldNum, 'string', card);
|
|
132
|
+
const res = await trySendRaw(userClient, chatId, 14, ckS, `field ${fieldNum} string=cardJSON`);
|
|
133
|
+
if (res.ok) {
|
|
134
|
+
console.log(`\n🎯 SUCCESS at field ${fieldNum}!`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
console.log('\n=== Phase B: special — Content field 14 is richText. Try CARD via richText? ===');
|
|
139
|
+
// Skip — richText is for POST type. CARD is different.
|
|
140
|
+
|
|
141
|
+
console.log('\n=== Phase C: combos — maybe card needs type at multiple fields ===');
|
|
142
|
+
// Like image needed thumb. Try card at field 8 + 9 + 10 + 16 etc.
|
|
143
|
+
// Skip for now — Phase A may have answered.
|
|
144
|
+
})().catch(e => { console.error('FATAL:', e.message); process.exit(1); });
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// Minimize IMAGE Content fields. Start with known-working combo, drop one field
|
|
4
|
+
// at a time and observe which omissions still pass.
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const protobuf = require('protobufjs');
|
|
9
|
+
|
|
10
|
+
const claudeCfg = JSON.parse(fs.readFileSync(path.join(require('os').homedir(), '.claude.json'), 'utf8'));
|
|
11
|
+
const env = claudeCfg.mcpServers?.['feishu-user-plugin']?.env || {};
|
|
12
|
+
process.env.LARK_COOKIE = env.LARK_COOKIE;
|
|
13
|
+
process.env.LARK_APP_ID = env.LARK_APP_ID;
|
|
14
|
+
process.env.LARK_APP_SECRET = env.LARK_APP_SECRET;
|
|
15
|
+
|
|
16
|
+
const PLUGIN_ROOT = path.join(__dirname, '..');
|
|
17
|
+
const { LarkUserClient } = require(path.join(PLUGIN_ROOT, 'src/clients/user'));
|
|
18
|
+
const { LarkOfficialClient } = require(path.join(PLUGIN_ROOT, 'src/clients/official'));
|
|
19
|
+
const { generateCid, generateRequestId } = require(path.join(PLUGIN_ROOT, 'src/utils'));
|
|
20
|
+
|
|
21
|
+
const TEST_IMAGE = path.join(PLUGIN_ROOT, '.playwright-mcp/captures/test-small.png');
|
|
22
|
+
const TEST_GROUP_NAME = '飞书plugin测试群';
|
|
23
|
+
|
|
24
|
+
const errProto = protobuf.parse(`
|
|
25
|
+
syntax = "proto3";
|
|
26
|
+
message ErrorResponse {
|
|
27
|
+
optional string message = 1;
|
|
28
|
+
optional int32 code = 2;
|
|
29
|
+
optional int32 subCode = 3;
|
|
30
|
+
optional string detail = 4;
|
|
31
|
+
optional string trace = 5;
|
|
32
|
+
optional string requestId = 6;
|
|
33
|
+
}
|
|
34
|
+
`).root;
|
|
35
|
+
const ErrorResponse = errProto.lookupType('ErrorResponse');
|
|
36
|
+
|
|
37
|
+
async function fetchProto(url, opts) {
|
|
38
|
+
const u = new URL(url);
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
const req = require('node:https').request({
|
|
41
|
+
hostname: u.hostname,
|
|
42
|
+
port: u.port || 443,
|
|
43
|
+
path: u.pathname + u.search,
|
|
44
|
+
method: opts.method || 'GET',
|
|
45
|
+
headers: opts.headers || {},
|
|
46
|
+
}, (res) => {
|
|
47
|
+
const chunks = [];
|
|
48
|
+
res.on('data', (c) => chunks.push(c));
|
|
49
|
+
res.on('end', () => resolve({ status: res.statusCode, body: Buffer.concat(chunks) }));
|
|
50
|
+
});
|
|
51
|
+
req.on('error', reject);
|
|
52
|
+
if (opts.body) req.write(opts.body);
|
|
53
|
+
req.end();
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function encodeVarint(n) {
|
|
58
|
+
const bytes = [];
|
|
59
|
+
while (n >= 0x80) {
|
|
60
|
+
bytes.push((n & 0x7f) | 0x80);
|
|
61
|
+
n = Math.floor(n / 128);
|
|
62
|
+
}
|
|
63
|
+
bytes.push(n & 0x7f);
|
|
64
|
+
return Buffer.from(bytes);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function encodeWireField(num, type, val) {
|
|
68
|
+
if (type === 'varint') return Buffer.concat([Buffer.from([(num << 3) | 0]), encodeVarint(val)]);
|
|
69
|
+
if (type === 'string') {
|
|
70
|
+
const v = Buffer.from(val, 'utf8');
|
|
71
|
+
return Buffer.concat([Buffer.from([(num << 3) | 2]), encodeVarint(v.length), v]);
|
|
72
|
+
}
|
|
73
|
+
throw new Error('unknown type');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function buildContent(fields) {
|
|
77
|
+
return Buffer.concat(fields.map(([n, t, v]) => encodeWireField(n, t, v)));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function trySendRaw(userClient, chatId, msgType, contentBytes, label) {
|
|
81
|
+
const proto = userClient.proto;
|
|
82
|
+
const PutMessageRequest = proto.lookupType('PutMessageRequest');
|
|
83
|
+
const Packet = proto.lookupType('Packet');
|
|
84
|
+
|
|
85
|
+
const restReq = PutMessageRequest.encode(PutMessageRequest.create({
|
|
86
|
+
type: msgType, chatId, cid: generateCid(), isNotified: true, version: 1,
|
|
87
|
+
})).finish();
|
|
88
|
+
const contentField = Buffer.concat([
|
|
89
|
+
Buffer.from([(2 << 3) | 2]),
|
|
90
|
+
encodeVarint(contentBytes.length),
|
|
91
|
+
contentBytes,
|
|
92
|
+
]);
|
|
93
|
+
const reqBuf = Buffer.concat([contentField, restReq]);
|
|
94
|
+
|
|
95
|
+
const packetBuf = Packet.encode(Packet.create({
|
|
96
|
+
payloadType: 1,
|
|
97
|
+
cmd: 5,
|
|
98
|
+
cid: generateRequestId(),
|
|
99
|
+
payload: reqBuf,
|
|
100
|
+
})).finish();
|
|
101
|
+
|
|
102
|
+
const res = await fetchProto('https://internal-api-lark-api.feishu.cn/im/gateway/', {
|
|
103
|
+
method: 'POST',
|
|
104
|
+
headers: userClient._protoHeaders(5, '5.7.0'),
|
|
105
|
+
body: packetBuf,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
let parsedErr = null;
|
|
109
|
+
try { parsedErr = ErrorResponse.toObject(ErrorResponse.decode(res.body)); } catch (_) {}
|
|
110
|
+
|
|
111
|
+
const status = res.status === 200 ? '✓ OK' : `✗ ${res.status}`;
|
|
112
|
+
const errMsg = parsedErr?.message?.slice(0, 100) || '';
|
|
113
|
+
console.log(`${status} [${label}] ${errMsg}`);
|
|
114
|
+
return { ok: res.status === 200, status: res.status, err: errMsg };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
(async () => {
|
|
118
|
+
const oc = new LarkOfficialClient(process.env.LARK_APP_ID, process.env.LARK_APP_SECRET);
|
|
119
|
+
const r = await oc.uploadImage(TEST_IMAGE, 'message');
|
|
120
|
+
const imageKey = r.imageKey;
|
|
121
|
+
console.log('imageKey:', imageKey);
|
|
122
|
+
|
|
123
|
+
const userClient = new LarkUserClient(process.env.LARK_COOKIE);
|
|
124
|
+
await userClient.init();
|
|
125
|
+
const sr = await userClient.search(TEST_GROUP_NAME);
|
|
126
|
+
const chatId = sr.find(x => x.type === 'group' && x.title.includes(TEST_GROUP_NAME)).id;
|
|
127
|
+
console.log('chatId:', chatId);
|
|
128
|
+
|
|
129
|
+
// ALL fields baseline (known to work)
|
|
130
|
+
const allFields = [
|
|
131
|
+
[2, 'string', imageKey], // imageKey
|
|
132
|
+
[4, 'varint', 50], // ?
|
|
133
|
+
[5, 'varint', 50], // ?
|
|
134
|
+
[8, 'string', 'image/png'], // mime?
|
|
135
|
+
[9, 'varint', 141], // size?
|
|
136
|
+
[10, 'string', imageKey], // thumbnail?
|
|
137
|
+
];
|
|
138
|
+
|
|
139
|
+
console.log('\n=== Verify baseline (all 6 fields) ===');
|
|
140
|
+
await trySendRaw(userClient, chatId, 5, buildContent(allFields), 'baseline all fields');
|
|
141
|
+
|
|
142
|
+
console.log('\n=== Drop each field individually ===');
|
|
143
|
+
// Field labels: imageKey is required; we test which of the OTHER 5 are required
|
|
144
|
+
const others = [4, 5, 8, 9, 10];
|
|
145
|
+
for (const dropField of others) {
|
|
146
|
+
const subset = allFields.filter(([n]) => n !== dropField);
|
|
147
|
+
const result = await trySendRaw(userClient, chatId, 5, buildContent(subset), `omit field ${dropField}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
console.log('\n=== Drop pairs / minimize ===');
|
|
151
|
+
// Just imageKey alone (already known to fail)
|
|
152
|
+
await trySendRaw(userClient, chatId, 5, buildContent([allFields[0]]), 'just imageKey');
|
|
153
|
+
// imageKey + field 10 (thumb)
|
|
154
|
+
await trySendRaw(userClient, chatId, 5, buildContent([allFields[0], allFields[5]]), 'imageKey + thumb(10)');
|
|
155
|
+
// imageKey + 4 + 5 (dims)
|
|
156
|
+
await trySendRaw(userClient, chatId, 5, buildContent([allFields[0], allFields[1], allFields[2]]), 'imageKey + 4 + 5');
|
|
157
|
+
// imageKey + 4 + 5 + 10
|
|
158
|
+
await trySendRaw(userClient, chatId, 5, buildContent([allFields[0], allFields[1], allFields[2], allFields[5]]), 'imageKey + 4 + 5 + 10');
|
|
159
|
+
// imageKey + 8 + 10 (mime + thumb)
|
|
160
|
+
await trySendRaw(userClient, chatId, 5, buildContent([allFields[0], allFields[3], allFields[5]]), 'imageKey + mime(8) + thumb(10)');
|
|
161
|
+
// imageKey + 4 + 5 + 8 (no size, no thumb)
|
|
162
|
+
await trySendRaw(userClient, chatId, 5, buildContent([allFields[0], allFields[1], allFields[2], allFields[3]]), 'imageKey + 4 + 5 + mime(8)');
|
|
163
|
+
})().catch(e => { console.error('FATAL:', e.message); process.exit(1); });
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// scripts/generate-release-artifacts.js
|
|
4
|
+
//
|
|
5
|
+
// Reads CHANGELOG.md for the latest version section + package.json + server.json,
|
|
6
|
+
// emits three deterministic artifacts for the release pipeline:
|
|
7
|
+
//
|
|
8
|
+
// /tmp/feishu-release/<version>/team-skills-changelog.md
|
|
9
|
+
// Markdown block ready to inject into team-skills child README's
|
|
10
|
+
// "## 更新日志" section, just before the previous "### vX.Y.Z" entry.
|
|
11
|
+
// Style mirrors the existing team-skills format (• bullets, no emoji,
|
|
12
|
+
// sections: 新增 / 调整 / 修复 / 下版本计划 / 升级方式).
|
|
13
|
+
//
|
|
14
|
+
// /tmp/feishu-release/<version>/team-skills-readme-row.md
|
|
15
|
+
// Single-line replacement for the root team-skills/README.md catalog
|
|
16
|
+
// row matching `| **feishu-user-plugin** | ...`.
|
|
17
|
+
//
|
|
18
|
+
// /tmp/feishu-release/<version>/feishu-card.json
|
|
19
|
+
// Feishu interactive card payload for `send_card_as_user`. Header
|
|
20
|
+
// template "blue", body sections separated by <hr>, each section
|
|
21
|
+
// uses lark_md for markdown rendering.
|
|
22
|
+
//
|
|
23
|
+
// Determinism contract — given the same CHANGELOG.md version section, this
|
|
24
|
+
// script emits the same artifacts byte-for-byte. No timestamps, no random IDs,
|
|
25
|
+
// no LLM passes. CHANGELOG must follow Keep a Changelog conventions:
|
|
26
|
+
//
|
|
27
|
+
// ## [X.Y.Z] - YYYY-MM-DD
|
|
28
|
+
//
|
|
29
|
+
// <one-paragraph summary> ← optional but recommended
|
|
30
|
+
//
|
|
31
|
+
// ### Added (translated to 新增)
|
|
32
|
+
// - **Title**: rest of bullet.
|
|
33
|
+
// - ...
|
|
34
|
+
//
|
|
35
|
+
// ### Changed (调整)
|
|
36
|
+
// ### Fixed (修复)
|
|
37
|
+
// ### Deferred to vN.M.P (下版本计划 (vN.M.P))
|
|
38
|
+
// ### Test scenarios (used in 升级方式 复测建议; optional)
|
|
39
|
+
// - bullet line, can be markdown
|
|
40
|
+
//
|
|
41
|
+
// Usage:
|
|
42
|
+
// node scripts/generate-release-artifacts.js (latest version)
|
|
43
|
+
// node scripts/generate-release-artifacts.js 1.3.8 (explicit)
|
|
44
|
+
//
|
|
45
|
+
// Exit codes:
|
|
46
|
+
// 0 success
|
|
47
|
+
// 1 missing inputs / parsing failure
|
|
48
|
+
// 2 invalid section structure
|
|
49
|
+
|
|
50
|
+
const fs = require('fs');
|
|
51
|
+
const path = require('path');
|
|
52
|
+
|
|
53
|
+
const ROOT = path.join(__dirname, '..');
|
|
54
|
+
|
|
55
|
+
const SECTION_TRANSLATE = {
|
|
56
|
+
added: '新增',
|
|
57
|
+
changed: '调整',
|
|
58
|
+
fixed: '修复',
|
|
59
|
+
removed: '移除',
|
|
60
|
+
deprecated: '废弃',
|
|
61
|
+
security: '安全',
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
function readChangelogSection(version) {
|
|
65
|
+
const text = fs.readFileSync(path.join(ROOT, 'CHANGELOG.md'), 'utf8');
|
|
66
|
+
// Anchor on `## [VERSION] - DATE`
|
|
67
|
+
const escVer = version.replace(/\./g, '\\.');
|
|
68
|
+
const start = new RegExp(`^##\\s*\\[${escVer}\\]\\s*-\\s*(\\d{4}-\\d{2}-\\d{2})\\s*$`, 'm');
|
|
69
|
+
const m = start.exec(text);
|
|
70
|
+
if (!m) throw new Error(`CHANGELOG.md has no section for v${version}`);
|
|
71
|
+
const date = m[1];
|
|
72
|
+
const after = text.slice(m.index + m[0].length);
|
|
73
|
+
// Find next `## ` (next version heading)
|
|
74
|
+
const next = after.match(/^##\s/m);
|
|
75
|
+
const body = next ? after.slice(0, next.index) : after;
|
|
76
|
+
return { date, body: body.trim() };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function parseSections(body) {
|
|
80
|
+
// Top opening paragraph (anything before the first `### `).
|
|
81
|
+
const firstSubheading = body.search(/^###\s/m);
|
|
82
|
+
let opening = '';
|
|
83
|
+
let rest = body;
|
|
84
|
+
if (firstSubheading > 0) {
|
|
85
|
+
opening = body.slice(0, firstSubheading).trim();
|
|
86
|
+
rest = body.slice(firstSubheading);
|
|
87
|
+
}
|
|
88
|
+
// Split into ### sections
|
|
89
|
+
const sections = {};
|
|
90
|
+
const parts = rest.split(/^### /m).filter(s => s.trim());
|
|
91
|
+
for (const part of parts) {
|
|
92
|
+
const lines = part.split('\n');
|
|
93
|
+
const title = lines[0].trim();
|
|
94
|
+
const content = lines.slice(1).join('\n').trim();
|
|
95
|
+
sections[title] = content;
|
|
96
|
+
}
|
|
97
|
+
return { opening, sections };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function bulletsFromSection(content) {
|
|
101
|
+
// CHANGELOG bullets typically begin with `- `. Multi-line bullets continue
|
|
102
|
+
// until the next `- ` or blank line. Normalize to one-bullet-per-line.
|
|
103
|
+
const out = [];
|
|
104
|
+
let cur = null;
|
|
105
|
+
for (const raw of content.split('\n')) {
|
|
106
|
+
if (raw.match(/^-\s+/)) {
|
|
107
|
+
if (cur) out.push(cur);
|
|
108
|
+
cur = raw.replace(/^-\s+/, '').trim();
|
|
109
|
+
} else if (cur && raw.trim()) {
|
|
110
|
+
cur += ' ' + raw.trim();
|
|
111
|
+
} else if (!raw.trim()) {
|
|
112
|
+
if (cur) { out.push(cur); cur = null; }
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (cur) out.push(cur);
|
|
116
|
+
return out.map(b => b.replace(/\s+/g, ' ').trim());
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function stripBoldPrefix(bullet) {
|
|
120
|
+
// CHANGELOG style: "**Title**: description" → keep both halves but drop **
|
|
121
|
+
// For card / announcement, we want plain readable lines.
|
|
122
|
+
return bullet.replace(/^\*\*(.+?)\*\*\s*[::]?\s*/, '$1: ').replace(/^\*\*(.+?)\*\*$/, '$1');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function generateTeamSkillsChangelog(version, date, parsed, prevVersion) {
|
|
126
|
+
const lines = [];
|
|
127
|
+
lines.push(`### v${version} (${date})`);
|
|
128
|
+
lines.push('');
|
|
129
|
+
if (parsed.opening) {
|
|
130
|
+
lines.push(parsed.opening);
|
|
131
|
+
lines.push('');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
for (const sectionName of ['Added', 'Changed', 'Fixed', 'Removed', 'Deprecated', 'Security']) {
|
|
135
|
+
const content = parsed.sections[sectionName];
|
|
136
|
+
if (!content) continue;
|
|
137
|
+
const zh = SECTION_TRANSLATE[sectionName.toLowerCase()];
|
|
138
|
+
lines.push(zh);
|
|
139
|
+
lines.push('');
|
|
140
|
+
for (const b of bulletsFromSection(content)) {
|
|
141
|
+
lines.push(`• ${stripBoldPrefix(b)}`);
|
|
142
|
+
}
|
|
143
|
+
lines.push('');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Deferred to vX.Y.Z → 下版本计划 (vX.Y.Z)
|
|
147
|
+
for (const [title, content] of Object.entries(parsed.sections)) {
|
|
148
|
+
const m = title.match(/^Deferred\s+to\s+(v[\d.]+)/i);
|
|
149
|
+
if (!m) continue;
|
|
150
|
+
lines.push(`下版本计划 (${m[1]})`);
|
|
151
|
+
lines.push('');
|
|
152
|
+
for (const b of bulletsFromSection(content)) {
|
|
153
|
+
lines.push(`• ${stripBoldPrefix(b)}`);
|
|
154
|
+
}
|
|
155
|
+
lines.push('');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// 升级方式
|
|
159
|
+
lines.push('升级方式');
|
|
160
|
+
lines.push('');
|
|
161
|
+
lines.push(`• 重启 Claude Code / Codex 自动拉取 ${version}`);
|
|
162
|
+
// Hint: if any bullet mentions "migrate" or "credentials.json", add a tip.
|
|
163
|
+
const allBullets = Object.values(parsed.sections).flatMap(c => bulletsFromSection(c)).join(' ');
|
|
164
|
+
if (/migrate|credentials\.json|FEISHU_PLUGIN_PROFILE/i.test(allBullets)) {
|
|
165
|
+
lines.push('• 推荐运行 npx feishu-user-plugin migrate --confirm 把凭证收敛到 ~/.feishu-user-plugin/credentials.json,然后 npx feishu-user-plugin setup --pointer-only 让 harness env 只放 FEISHU_PLUGIN_PROFILE 指针');
|
|
166
|
+
}
|
|
167
|
+
if (/WS|WebSocket|get_new_events/i.test(allBullets)) {
|
|
168
|
+
lines.push('• 启动看 stderr 带 "WS connected" 表示实时事件可用;看到 "WS start failed" 是 Lark 国际版或网络限制');
|
|
169
|
+
}
|
|
170
|
+
// Test scenarios from optional section
|
|
171
|
+
const ts = parsed.sections['Test scenarios'];
|
|
172
|
+
if (ts) {
|
|
173
|
+
const items = bulletsFromSection(ts).map(b => stripBoldPrefix(b));
|
|
174
|
+
if (items.length > 0) {
|
|
175
|
+
lines.push(`• 建议复测 ${items.length} 个场景:${items.join(';')}`);
|
|
176
|
+
}
|
|
177
|
+
} else {
|
|
178
|
+
// Fallback: list top-3 Added bullet titles
|
|
179
|
+
const added = parsed.sections['Added'];
|
|
180
|
+
if (added) {
|
|
181
|
+
const titles = bulletsFromSection(added)
|
|
182
|
+
.map(b => {
|
|
183
|
+
const m = b.match(/^\*\*([^*]+)\*\*/);
|
|
184
|
+
return m ? m[1].replace(/\s*\([^)]+\)\s*$/, '') : null;
|
|
185
|
+
})
|
|
186
|
+
.filter(Boolean)
|
|
187
|
+
.slice(0, 3);
|
|
188
|
+
if (titles.length) lines.push(`• 建议复测核心新功能场景:${titles.join(';')}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
lines.push('');
|
|
192
|
+
|
|
193
|
+
// Tool count line — read from server.json to be canonical.
|
|
194
|
+
try {
|
|
195
|
+
const tools = require(path.join(ROOT, 'server.json')).tools.length;
|
|
196
|
+
if (prevVersion) {
|
|
197
|
+
lines.push(`• 工具数:${prevVersion.tools} → **${tools}**`);
|
|
198
|
+
} else {
|
|
199
|
+
lines.push(`• 工具数:**${tools}**`);
|
|
200
|
+
}
|
|
201
|
+
} catch (_) {}
|
|
202
|
+
lines.push('');
|
|
203
|
+
|
|
204
|
+
return lines.join('\n');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function generateRootReadmeRow(version, packageDescription) {
|
|
208
|
+
// Format must match team-skills/README.md catalog table:
|
|
209
|
+
// | **feishu-user-plugin** | <ver> | <desc> | EthanQC | 1 | - |
|
|
210
|
+
return `| **feishu-user-plugin** | ${version} | ${packageDescription} | EthanQC | 1 | - |`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function generateCard(version, date, parsed) {
|
|
214
|
+
const elements = [];
|
|
215
|
+
|
|
216
|
+
// Opening paragraph
|
|
217
|
+
if (parsed.opening) {
|
|
218
|
+
elements.push({ tag: 'div', text: { tag: 'lark_md', content: parsed.opening } });
|
|
219
|
+
elements.push({ tag: 'hr' });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Sections
|
|
223
|
+
const sectionOrder = ['Added', 'Changed', 'Fixed', 'Removed'];
|
|
224
|
+
for (const name of sectionOrder) {
|
|
225
|
+
const content = parsed.sections[name];
|
|
226
|
+
if (!content) continue;
|
|
227
|
+
const zh = SECTION_TRANSLATE[name.toLowerCase()];
|
|
228
|
+
const bullets = bulletsFromSection(content)
|
|
229
|
+
.map(b => `- ${stripBoldPrefix(b)}`)
|
|
230
|
+
.join('\n');
|
|
231
|
+
elements.push({ tag: 'div', text: { tag: 'lark_md', content: `**${zh}**\n${bullets}` } });
|
|
232
|
+
elements.push({ tag: 'hr' });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Deferred → 下版本计划
|
|
236
|
+
for (const [title, content] of Object.entries(parsed.sections)) {
|
|
237
|
+
const m = title.match(/^Deferred\s+to\s+(v[\d.]+)/i);
|
|
238
|
+
if (!m) continue;
|
|
239
|
+
const bullets = bulletsFromSection(content)
|
|
240
|
+
.map(b => `- ${stripBoldPrefix(b)}`)
|
|
241
|
+
.join('\n');
|
|
242
|
+
elements.push({ tag: 'div', text: { tag: 'lark_md', content: `**下版本计划 (${m[1]})**\n${bullets}` } });
|
|
243
|
+
elements.push({ tag: 'hr' });
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// 升级方式 — same template as team-skills changelog
|
|
247
|
+
const upgradeLines = [`- 重启 Claude Code / Codex 自动拉取 ${version}`];
|
|
248
|
+
const allBullets = Object.values(parsed.sections).flatMap(c => bulletsFromSection(c)).join(' ');
|
|
249
|
+
if (/migrate|credentials\.json|FEISHU_PLUGIN_PROFILE/i.test(allBullets)) {
|
|
250
|
+
upgradeLines.push('- 推荐运行 `npx feishu-user-plugin migrate --confirm` 收敛凭证,再 `setup --pointer-only` 仅写 `FEISHU_PLUGIN_PROFILE` 指针');
|
|
251
|
+
}
|
|
252
|
+
if (/WS|WebSocket|get_new_events/i.test(allBullets)) {
|
|
253
|
+
upgradeLines.push('- 启动 stderr 带 `WS connected` 表示实时事件可用;`WS start failed` 是 Lark 国际版 / 网络限制');
|
|
254
|
+
}
|
|
255
|
+
const ts = parsed.sections['Test scenarios'];
|
|
256
|
+
if (ts) {
|
|
257
|
+
const items = bulletsFromSection(ts).map(b => stripBoldPrefix(b));
|
|
258
|
+
upgradeLines.push(`- 建议复测 ${items.length} 个场景:${items.join(';')}`);
|
|
259
|
+
} else {
|
|
260
|
+
const added = parsed.sections['Added'];
|
|
261
|
+
if (added) {
|
|
262
|
+
const titles = bulletsFromSection(added)
|
|
263
|
+
.map(b => { const m = b.match(/^\*\*([^*]+)\*\*/); return m ? m[1].replace(/\s*\([^)]+\)\s*$/, '') : null; })
|
|
264
|
+
.filter(Boolean)
|
|
265
|
+
.slice(0, 3);
|
|
266
|
+
if (titles.length) upgradeLines.push(`- 建议复测核心新功能场景:${titles.join(';')}`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
elements.push({ tag: 'div', text: { tag: 'lark_md', content: `**升级方式**\n${upgradeLines.join('\n')}` } });
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
config: { wide_screen_mode: true },
|
|
273
|
+
header: {
|
|
274
|
+
template: 'blue',
|
|
275
|
+
title: { tag: 'plain_text', content: `feishu-user-plugin v${version} 发布` },
|
|
276
|
+
},
|
|
277
|
+
elements,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function main() {
|
|
282
|
+
const explicit = process.argv[2];
|
|
283
|
+
const pkg = require(path.join(ROOT, 'package.json'));
|
|
284
|
+
const version = explicit || pkg.version;
|
|
285
|
+
|
|
286
|
+
const { date, body } = readChangelogSection(version);
|
|
287
|
+
const parsed = parseSections(body);
|
|
288
|
+
|
|
289
|
+
// Sanity check: at least one of Added/Changed/Fixed must be present.
|
|
290
|
+
const hasAny = ['Added', 'Changed', 'Fixed', 'Removed', 'Deprecated', 'Security']
|
|
291
|
+
.some(n => parsed.sections[n]);
|
|
292
|
+
if (!hasAny) {
|
|
293
|
+
console.error(`CHANGELOG section for v${version} has no recognized subsection.`);
|
|
294
|
+
process.exit(2);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const outDir = path.join('/tmp/feishu-release', `v${version}`);
|
|
298
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
299
|
+
|
|
300
|
+
// Compute previous tool count from server.json git history? Best-effort:
|
|
301
|
+
// Tool count delta is omitted unless caller passes it — we don't currently
|
|
302
|
+
// have a clean way to get the previous version's tool count without git.
|
|
303
|
+
const teamSkillsBlock = generateTeamSkillsChangelog(version, date, parsed, null);
|
|
304
|
+
fs.writeFileSync(path.join(outDir, 'team-skills-changelog.md'), teamSkillsBlock);
|
|
305
|
+
|
|
306
|
+
const rootRow = generateRootReadmeRow(version, pkg.description);
|
|
307
|
+
fs.writeFileSync(path.join(outDir, 'team-skills-readme-row.md'), rootRow + '\n');
|
|
308
|
+
|
|
309
|
+
const card = generateCard(version, date, parsed);
|
|
310
|
+
fs.writeFileSync(path.join(outDir, 'feishu-card.json'), JSON.stringify(card, null, 2) + '\n');
|
|
311
|
+
|
|
312
|
+
console.log(`Wrote: ${outDir}/`);
|
|
313
|
+
console.log(` team-skills-changelog.md (${teamSkillsBlock.length} chars)`);
|
|
314
|
+
console.log(` team-skills-readme-row.md (${rootRow.length} chars)`);
|
|
315
|
+
console.log(` feishu-card.json (${JSON.stringify(card).length} chars)`);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
main();
|