feishu-user-plugin 1.3.8 → 1.3.10
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 +12 -2
- package/CHANGELOG.md +100 -12
- package/README.en.md +610 -0
- package/README.md +292 -532
- package/package.json +12 -5
- package/proto/lark.proto +10 -0
- package/scripts/explore-card-protobuf.js +144 -0
- package/scripts/explore-image-minimize.js +163 -0
- package/scripts/generate-og-image.js +39 -0
- package/scripts/generate-release-artifacts.js +318 -0
- package/scripts/probe-feishu-docx.js +203 -0
- package/scripts/sync-team-skills.sh +109 -7
- package/skills/feishu-user-plugin/SKILL.md +76 -4
- package/skills/feishu-user-plugin/references/CLAUDE.md +74 -54
- package/src/auth/credentials.js +36 -0
- package/src/cli.js +86 -45
- package/src/clients/user.js +15 -13
- package/src/events/cursor.js +103 -0
- package/src/events/event-buffer.js +8 -5
- package/src/events/event-log.js +151 -0
- package/src/events/index.js +8 -1
- package/src/events/lockfile.js +126 -0
- package/src/events/owner.js +73 -0
- package/src/events/ws-server.js +95 -25
- package/src/oauth.js +48 -7
- package/src/resolver.js +10 -0
- package/src/server.js +248 -29
- package/src/setup.js +99 -25
- 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/diagnostics.js +10 -3
- package/src/tools/docs.js +93 -3
- package/src/tools/events.js +143 -33
- package/src/tools/messaging-bot.js +2 -3
- package/src/tools/messaging-user.js +23 -14
- package/src/tools/profile.js +12 -7
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "feishu-user-plugin",
|
|
3
|
-
"
|
|
4
|
-
"
|
|
3
|
+
"mcpName": "io.github.EthanQC/feishu-user-plugin",
|
|
4
|
+
"version": "1.3.10",
|
|
5
|
+
"description": "All-in-one Feishu MCP server for Claude Code & Codex — 84 tools across 3 auth layers (cookie / app / OAuth). Send as you, read groups, manage docs / bitable / wiki / drive / calendar / tasks / OKR.",
|
|
5
6
|
"main": "src/index.js",
|
|
6
7
|
"bin": {
|
|
7
8
|
"feishu-user-plugin": "src/cli.js"
|
|
@@ -49,18 +50,24 @@
|
|
|
49
50
|
".env.example",
|
|
50
51
|
"CHANGELOG.md",
|
|
51
52
|
"README.md",
|
|
53
|
+
"README.en.md",
|
|
52
54
|
"LICENSE"
|
|
53
55
|
],
|
|
54
56
|
"engines": {
|
|
55
57
|
"node": ">=18.0.0"
|
|
56
58
|
},
|
|
57
59
|
"dependencies": {
|
|
58
|
-
"@larksuiteoapi/node-sdk": "^1.
|
|
59
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
60
|
+
"@larksuiteoapi/node-sdk": "^1.63.1",
|
|
61
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
60
62
|
"dotenv": "^16.4.7",
|
|
61
|
-
"
|
|
63
|
+
"feishu-docx": "^0.7.0",
|
|
64
|
+
"protobufjs": "^7.5.6"
|
|
62
65
|
},
|
|
63
66
|
"devDependencies": {
|
|
67
|
+
"@resvg/resvg-js": "^2.6.2",
|
|
64
68
|
"husky": "^9.1.7"
|
|
69
|
+
},
|
|
70
|
+
"overrides": {
|
|
71
|
+
"axios": "^1.16.0"
|
|
65
72
|
}
|
|
66
73
|
}
|
package/proto/lark.proto
CHANGED
|
@@ -123,8 +123,18 @@ message Content {
|
|
|
123
123
|
optional string text = 1;
|
|
124
124
|
optional string imageKey = 2;
|
|
125
125
|
optional string title = 3;
|
|
126
|
+
// Image metadata fields (v1.3.9 — reverse-engineered via brute-force probe;
|
|
127
|
+
// see scripts/explore-image-minimize.js). Required for IMAGE messages on the
|
|
128
|
+
// cookie-protobuf path: imageKey + thumbnailKey(10) is the minimum the
|
|
129
|
+
// gateway accepts. width(4) / height(5) / mimeType(8) / fileSize(9) are
|
|
130
|
+
// optional but commonly present in the real wire format.
|
|
131
|
+
optional int32 imageWidth = 4;
|
|
132
|
+
optional int32 imageHeight = 5;
|
|
126
133
|
optional string fileKey = 6;
|
|
127
134
|
optional string audioKey = 7;
|
|
135
|
+
optional string mimeType = 8;
|
|
136
|
+
optional int64 fileSize = 9;
|
|
137
|
+
optional string thumbnailKey = 10;
|
|
128
138
|
optional string fileName = 11;
|
|
129
139
|
optional RichText richText = 14;
|
|
130
140
|
optional string stickerSetId = 24;
|
|
@@ -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,39 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Render docs/og.svg → docs/og.png at 1200x630.
|
|
3
|
+
//
|
|
4
|
+
// Idempotent. Run `node scripts/generate-og-image.js` after editing
|
|
5
|
+
// docs/og.svg. Commit both the SVG (source) and the PNG (asset used
|
|
6
|
+
// by social-media unfurls and `<meta property="og:image">`).
|
|
7
|
+
//
|
|
8
|
+
// Why PNG: Twitter / WeChat / 飞书 unfurls don't render SVG `og:image`.
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const { Resvg } = require('@resvg/resvg-js');
|
|
15
|
+
|
|
16
|
+
const svgPath = path.join(__dirname, '..', 'docs', 'og.svg');
|
|
17
|
+
const pngPath = path.join(__dirname, '..', 'docs', 'og.png');
|
|
18
|
+
|
|
19
|
+
const svg = fs.readFileSync(svgPath, 'utf8');
|
|
20
|
+
|
|
21
|
+
const resvg = new Resvg(svg, {
|
|
22
|
+
fitTo: { mode: 'width', value: 1200 },
|
|
23
|
+
// Try to use system fonts so Chinese characters render. resvg-js loads
|
|
24
|
+
// fonts from `font.fontDirs` and `font.defaultFontFamily`. On macOS
|
|
25
|
+
// /System/Library/Fonts has PingFang SC; on Linux CI, jekyll-seo-tag
|
|
26
|
+
// doesn't run this script anyway — only humans do, locally.
|
|
27
|
+
font: {
|
|
28
|
+
fontDirs: ['/System/Library/Fonts', '/Library/Fonts', '/usr/share/fonts'],
|
|
29
|
+
loadSystemFonts: true,
|
|
30
|
+
defaultFontFamily: 'PingFang SC',
|
|
31
|
+
},
|
|
32
|
+
background: '#0d1117',
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const pngBuffer = resvg.render().asPng();
|
|
36
|
+
fs.writeFileSync(pngPath, pngBuffer);
|
|
37
|
+
|
|
38
|
+
const sizeKb = (pngBuffer.length / 1024).toFixed(1);
|
|
39
|
+
console.log(`OK: wrote ${pngPath} (${pngBuffer.length} bytes / ${sizeKb} KiB)`);
|