@kin-tio/cli 0.6.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.
Files changed (66) hide show
  1. package/.env.example +46 -0
  2. package/CHANGELOG.md +95 -0
  3. package/LICENSE +202 -0
  4. package/README.md +150 -0
  5. package/README.zh-CN.md +79 -0
  6. package/THIRD_PARTY_NOTICES +31 -0
  7. package/assets/ilink-login-card.png +0 -0
  8. package/bin/kintio.js +3 -0
  9. package/codex-workspace/.agents/skills/wechat-kf-reply-sop/SKILL.md +58 -0
  10. package/dist/cli.js +3 -0
  11. package/dist/daemon.js +28 -0
  12. package/dist/index.js +70 -0
  13. package/dist/mcp-relay.js +11 -0
  14. package/dist/src/agent/runtime.js +1 -0
  15. package/dist/src/app.js +34 -0
  16. package/dist/src/cli.js +578 -0
  17. package/dist/src/config.js +237 -0
  18. package/dist/src/domain/message.js +23 -0
  19. package/dist/src/domain/send-contract.js +205 -0
  20. package/dist/src/domain/wecom-message.js +281 -0
  21. package/dist/src/ilink/executor.js +306 -0
  22. package/dist/src/ilink/inbound-image.js +310 -0
  23. package/dist/src/ilink/listener.js +306 -0
  24. package/dist/src/ilink/login-manager.js +198 -0
  25. package/dist/src/ilink/login-store.js +197 -0
  26. package/dist/src/ilink/media-gateway.js +83 -0
  27. package/dist/src/ilink/media.js +267 -0
  28. package/dist/src/ilink/message.js +247 -0
  29. package/dist/src/ilink/protocol/client.js +464 -0
  30. package/dist/src/ilink/protocol/types.js +35 -0
  31. package/dist/src/ilink/qr.js +109 -0
  32. package/dist/src/ilink/secret-box.js +143 -0
  33. package/dist/src/ilink/sqlite-store.js +1194 -0
  34. package/dist/src/ilink/store-types.js +63 -0
  35. package/dist/src/lib/image-format.js +23 -0
  36. package/dist/src/lib/path-identity.js +38 -0
  37. package/dist/src/lib/private-directory.js +51 -0
  38. package/dist/src/lib/text.js +19 -0
  39. package/dist/src/lib/wecom-crypto.js +74 -0
  40. package/dist/src/lib/xml.js +8 -0
  41. package/dist/src/mcp/conversation-memory-server.js +179 -0
  42. package/dist/src/mcp/ilink-server.js +158 -0
  43. package/dist/src/mcp/ipc-host.js +275 -0
  44. package/dist/src/mcp/ipc-protocol.js +226 -0
  45. package/dist/src/mcp/stdio-relay.js +122 -0
  46. package/dist/src/mcp/wechat-kf-executor.js +295 -0
  47. package/dist/src/mcp/wechat-kf-server.js +208 -0
  48. package/dist/src/routes/wecom.js +89 -0
  49. package/dist/src/runtime/daemon-protocol.js +202 -0
  50. package/dist/src/runtime/managed-skill.js +49 -0
  51. package/dist/src/runtime/native-daemon.js +325 -0
  52. package/dist/src/runtime/single-instance-lock.js +167 -0
  53. package/dist/src/runtime.js +503 -0
  54. package/dist/src/services/codex-agent.js +542 -0
  55. package/dist/src/services/codex-app-server.js +436 -0
  56. package/dist/src/services/conversation-processor.js +762 -0
  57. package/dist/src/services/image-stager.js +49 -0
  58. package/dist/src/services/media-gateway.js +83 -0
  59. package/dist/src/services/wecom-api.js +311 -0
  60. package/dist/src/services/wecom-sync.js +316 -0
  61. package/dist/src/state/persistence.js +124 -0
  62. package/dist/src/state/sqlite-store.js +3102 -0
  63. package/dist/src/supervisor.js +212 -0
  64. package/dist/src/types.js +1 -0
  65. package/dist/src/version.js +1 -0
  66. package/package.json +72 -0
@@ -0,0 +1,295 @@
1
+ import { normalizeSendIntent, } from '../domain/send-contract.js';
2
+ import { AgentSessionError, } from '../state/sqlite-store.js';
3
+ import { WecomApiError } from '../services/wecom-api.js';
4
+ function exactPayload(message, mediaId = '') {
5
+ switch (message.type) {
6
+ case 'text':
7
+ return { msgtype: 'text', text: { content: message.content } };
8
+ case 'image':
9
+ return { msgtype: 'image', image: { media_id: mediaId } };
10
+ case 'link':
11
+ return {
12
+ msgtype: 'link',
13
+ link: {
14
+ title: message.title,
15
+ desc: message.description,
16
+ url: message.url,
17
+ thumb_media_id: mediaId,
18
+ },
19
+ };
20
+ case 'miniprogram':
21
+ return {
22
+ msgtype: 'miniprogram',
23
+ miniprogram: {
24
+ appid: message.appId,
25
+ title: message.title,
26
+ pagepath: message.pagePath,
27
+ thumb_media_id: mediaId,
28
+ },
29
+ };
30
+ case 'location': {
31
+ const { type, ...location } = message;
32
+ return { msgtype: type, location };
33
+ }
34
+ }
35
+ }
36
+ function attemptError(attempt) {
37
+ if (attempt.status === 'failed' && attempt.failType === 13) {
38
+ return {
39
+ kind: 'sensitive_content',
40
+ message: 'The channel rejected this message as potentially sensitive content. Do not send unlawful content; if the request is legitimate, revise the wording before deciding whether to try once more.',
41
+ failType: 13,
42
+ };
43
+ }
44
+ if (attempt.status === 'failed') {
45
+ return {
46
+ kind: 'wechat_delivery_failed',
47
+ message: attempt.errorMessage || 'Channel message delivery failed',
48
+ ...(attempt.errorCode ? { code: attempt.errorCode } : {}),
49
+ ...(attempt.failType ? { failType: attempt.failType } : {}),
50
+ };
51
+ }
52
+ if (attempt.status === 'uncertain') {
53
+ return {
54
+ kind: 'uncertain_result',
55
+ message: attempt.errorMessage || 'Channel API result is uncertain',
56
+ ...(attempt.errorCode ? { code: attempt.errorCode } : {}),
57
+ };
58
+ }
59
+ return undefined;
60
+ }
61
+ function receipt(attempt) {
62
+ const status = attempt.status === 'failed'
63
+ ? 'failed'
64
+ : attempt.status === 'uncertain'
65
+ ? 'uncertain'
66
+ : 'accepted';
67
+ const error = attemptError(attempt);
68
+ return {
69
+ status,
70
+ attemptId: attempt.attemptId,
71
+ sendIndex: attempt.sendIndex,
72
+ type: attempt.type,
73
+ providerMessageId: attempt.providerMessageId,
74
+ ...(error ? { error } : {}),
75
+ };
76
+ }
77
+ function definitive(error) {
78
+ return error instanceof WecomApiError && error.code !== undefined;
79
+ }
80
+ export class WechatKfToolExecutor {
81
+ #store;
82
+ #api;
83
+ #media;
84
+ #observeMs;
85
+ #pollMs;
86
+ #sleep;
87
+ #logger;
88
+ #ilinkOffers;
89
+ #draining;
90
+ #rerun = false;
91
+ #closed = false;
92
+ constructor({ store, apiClient, mediaGateway, observeMs = 5_000, pollMs = 100, sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), logger = console, ilinkOffers, }) {
93
+ this.#store = store;
94
+ this.#api = apiClient;
95
+ this.#media = mediaGateway;
96
+ this.#observeMs = Math.max(0, Math.min(Number(observeMs) || 0, 20_000));
97
+ this.#pollMs = Math.max(10, Math.min(Number(pollMs) || 100, 1_000));
98
+ this.#sleep = sleep;
99
+ this.#logger = logger;
100
+ this.#ilinkOffers = ilinkOffers;
101
+ }
102
+ async #payload(sessionToken, toolName, input) {
103
+ const session = this.#store.getAgentSession(sessionToken);
104
+ const artifactRef = String(input.mediaRef || '').startsWith('artifact:')
105
+ ? [{ ref: String(input.mediaRef), kind: 'image' }]
106
+ : [];
107
+ const intent = normalizeSendIntent(toolName, input, {
108
+ mediaCatalog: [
109
+ ...session.mediaCatalog.map(({ ref, kind }) => ({ ref, kind })),
110
+ ...artifactRef,
111
+ ],
112
+ });
113
+ let mediaId = '';
114
+ let metadata;
115
+ try {
116
+ if (intent.type === 'image') {
117
+ if (intent.mediaRef.startsWith('artifact:')) {
118
+ const artifact = this.#store.getAgentArtifact(sessionToken, intent.mediaRef);
119
+ metadata = artifact.metadata;
120
+ mediaId = (await this.#media.upload({
121
+ kind: 'image',
122
+ bytes: artifact.bytes,
123
+ filename: artifact.filename,
124
+ contentType: artifact.contentType,
125
+ })).media_id;
126
+ }
127
+ else {
128
+ const source = session.mediaCatalog.find((item) => item.ref === intent.mediaRef);
129
+ if (!source) {
130
+ throw new AgentSessionError('The image reference is unavailable in this agent session', 'invalid_media_reference');
131
+ }
132
+ mediaId = await this.#media.cloneForSend({
133
+ kind: 'image',
134
+ sourceMediaId: source.mediaId,
135
+ filename: source.filename,
136
+ });
137
+ }
138
+ }
139
+ else if (intent.type === 'link' || intent.type === 'miniprogram') {
140
+ mediaId = await this.#media.getCardThumbnailMediaId();
141
+ }
142
+ }
143
+ catch (error) {
144
+ if (error instanceof AgentSessionError)
145
+ throw error;
146
+ throw new AgentSessionError(`Media preparation failed before send_msg was called: ${error instanceof Error ? error.message : String(error)}`, 'media_preparation_failed');
147
+ }
148
+ return {
149
+ type: intent.type,
150
+ payload: exactPayload(intent, mediaId),
151
+ ...(metadata ? { metadata } : {}),
152
+ };
153
+ }
154
+ async #observe(attemptId) {
155
+ const deadline = Date.now() + this.#observeMs;
156
+ let current = this.#store.getAttempt(attemptId);
157
+ if (!current)
158
+ throw new Error(`Missing WeChat attempt ${attemptId}`);
159
+ while (current.status === 'accepted' && Date.now() < deadline) {
160
+ await this.#sleep(Math.min(this.#pollMs, Math.max(1, deadline - Date.now())));
161
+ current = this.#store.getAttempt(attemptId);
162
+ if (!current)
163
+ throw new Error(`Missing WeChat attempt ${attemptId}`);
164
+ }
165
+ return current;
166
+ }
167
+ async #transmit(attempt, observe) {
168
+ let completed;
169
+ try {
170
+ if (!attempt.payload)
171
+ throw new Error('Pending channel attempt has no payload');
172
+ const result = await this.#api.sendPreparedMessage({
173
+ toUser: attempt.peerId,
174
+ openKfId: attempt.accountKey,
175
+ payload: attempt.payload,
176
+ messageId: attempt.clientMessageId,
177
+ });
178
+ const providerMessageId = String(result.msgid || '');
179
+ if (!providerMessageId) {
180
+ throw new Error('The channel API accepted the request without returning msgid');
181
+ }
182
+ completed = this.#store.completeSend(attempt.attemptId, {
183
+ providerMessageId,
184
+ });
185
+ }
186
+ catch (error) {
187
+ return definitive(error)
188
+ ? this.#store.failSend(attempt.attemptId, error)
189
+ : this.#store.markSendUncertain(attempt.attemptId, error);
190
+ }
191
+ return observe ? this.#observe(attempt.attemptId) : completed;
192
+ }
193
+ async #sendPrepared({ sessionToken, type, payload, metadata, }) {
194
+ const attempt = this.#store.reserveAgentSend({
195
+ sessionToken,
196
+ sentType: type,
197
+ payload,
198
+ metadata,
199
+ });
200
+ return receipt(await this.#transmit(attempt, true));
201
+ }
202
+ async execute(toolName, input) {
203
+ const sessionToken = String(input.session || '');
204
+ const session = this.#store.getAgentSession(sessionToken);
205
+ if (session.channel !== 'wechat_kf') {
206
+ throw new AgentSessionError('Agent session is bound to another channel', 'wrong_channel');
207
+ }
208
+ if (toolName === 'offer_weixin_bot_channel') {
209
+ if (!this.#ilinkOffers) {
210
+ throw new AgentSessionError('iLink channel invitations are unavailable', 'ilink_unavailable');
211
+ }
212
+ const offered = await this.#ilinkOffers.offer(sessionToken);
213
+ try {
214
+ const uploaded = await this.#media.upload({
215
+ kind: 'image',
216
+ bytes: offered.png,
217
+ filename: 'weixin-ilink-login.png',
218
+ contentType: 'image/png',
219
+ });
220
+ const result = await this.#sendPrepared({
221
+ sessionToken,
222
+ type: 'image',
223
+ payload: { msgtype: 'image', image: { media_id: uploaded.media_id } },
224
+ metadata: { tool: toolName, offerId: offered.offerId },
225
+ });
226
+ if (result.status === 'failed')
227
+ this.#ilinkOffers.cancel(offered.offerId);
228
+ return result;
229
+ }
230
+ catch (error) {
231
+ this.#ilinkOffers.cancel(offered.offerId);
232
+ throw error;
233
+ }
234
+ }
235
+ const { session: _session, ...argumentsWithoutSession } = input;
236
+ const prepared = await this.#payload(sessionToken, toolName, argumentsWithoutSession);
237
+ return this.#sendPrepared({
238
+ sessionToken,
239
+ type: prepared.type,
240
+ payload: prepared.payload,
241
+ metadata: {
242
+ ...(prepared.metadata || {}),
243
+ tool: prepared.metadata ? 'generated_image' : toolName,
244
+ },
245
+ });
246
+ }
247
+ kick() {
248
+ if (this.#closed)
249
+ return Promise.resolve();
250
+ this.#rerun = true;
251
+ this.#draining ||= this.#runDrain().finally(() => {
252
+ this.#draining = undefined;
253
+ if (this.#rerun && !this.#closed)
254
+ void this.kick();
255
+ });
256
+ return this.waitForIdle();
257
+ }
258
+ async #runDrain() {
259
+ do {
260
+ this.#rerun = false;
261
+ await this.#drain();
262
+ } while (this.#rerun && !this.#closed);
263
+ }
264
+ async #drain() {
265
+ while (!this.#closed) {
266
+ const attempt = this.#store.beginNextSend('wechat_kf');
267
+ if (!attempt)
268
+ return;
269
+ const settled = await this.#transmit(attempt, false);
270
+ if (settled.status === 'accepted') {
271
+ this.#logger.info?.(`[wechat-kf-mcp] accepted type=${attempt.type} attempt=${attempt.attemptId}`);
272
+ }
273
+ else if (settled.status === 'failed') {
274
+ this.#logger.warn?.(`[wechat-kf-mcp] failed attempt=${attempt.attemptId}`);
275
+ }
276
+ else {
277
+ this.#logger.error?.(`[wechat-kf-mcp] uncertain attempt=${attempt.attemptId}`);
278
+ }
279
+ }
280
+ }
281
+ async waitForIdle() {
282
+ while (this.#draining)
283
+ await this.#draining;
284
+ }
285
+ async close() {
286
+ if (this.#closed)
287
+ return;
288
+ await this.kick();
289
+ this.#closed = true;
290
+ }
291
+ abort() {
292
+ this.#closed = true;
293
+ this.#rerun = false;
294
+ }
295
+ }
@@ -0,0 +1,208 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import * as z from 'zod/v4';
3
+ import { KINTIO_VERSION } from '../version.js';
4
+ import { WechatKfToolExecutor, } from './wechat-kf-executor.js';
5
+ const TOOL_ANNOTATIONS = Object.freeze({
6
+ readOnlyHint: false,
7
+ destructiveHint: false,
8
+ idempotentHint: false,
9
+ openWorldHint: true,
10
+ });
11
+ const SEND_TYPES = [
12
+ 'text',
13
+ 'image',
14
+ 'link',
15
+ 'miniprogram',
16
+ 'location',
17
+ ];
18
+ const ATTEMPT_ID = /^sa_[A-Za-z0-9_-]+$/u;
19
+ const SAFE_CODE = /^[A-Za-z0-9_.:-]{1,128}$/u;
20
+ const ERROR_KINDS = [
21
+ 'sensitive_content',
22
+ 'wechat_delivery_failed',
23
+ 'uncertain_result',
24
+ 'invalid_agent_session',
25
+ 'closed_agent_session',
26
+ 'expired_agent_session',
27
+ 'stale_agent_session',
28
+ 'send_budget_exceeded',
29
+ 'wrong_channel',
30
+ 'invalid_media_reference',
31
+ 'media_preparation_failed',
32
+ 'ilink_unavailable',
33
+ 'invalid_send_intent',
34
+ 'invalid_media_catalog',
35
+ 'unsafe_media_catalog',
36
+ 'unsupported_send_type',
37
+ 'wechat_tool_error',
38
+ ];
39
+ const SAFE_ERROR_MESSAGES = {
40
+ sensitive_content: 'The channel rejected this message as potentially sensitive content. Do not send unlawful content; if the request is legitimate, revise the wording before deciding whether to try once more.',
41
+ wechat_delivery_failed: 'The channel rejected this message.',
42
+ uncertain_result: 'The delivery result is uncertain and the message may already have been sent. Do not retry merely because the outcome is unknown.',
43
+ invalid_agent_session: 'The conversation capability is invalid. Wait for the host runtime to provide a new session.',
44
+ closed_agent_session: 'The conversation session is closed. Wait for the host runtime to provide a new session.',
45
+ expired_agent_session: 'The conversation session expired. Wait for the host runtime to provide a new session.',
46
+ stale_agent_session: 'This conversation direction is stale. Continue from the participant\'s latest message.',
47
+ send_budget_exceeded: 'The reply budget for this conversation is exhausted. Stop sending.',
48
+ wrong_channel: 'This session does not belong to the WeChat KF adapter.',
49
+ invalid_media_reference: 'This conversation cannot use the requested image reference.',
50
+ media_preparation_failed: 'Media preparation failed before any message was sent.',
51
+ ilink_unavailable: 'An independent iLink Bot conversation cannot be established right now.',
52
+ invalid_send_intent: 'The message parameters do not satisfy the adapter requirements.',
53
+ invalid_media_catalog: 'The conversation media catalog is invalid.',
54
+ unsafe_media_catalog: 'The conversation media catalog contains unsafe fields.',
55
+ unsupported_send_type: 'This adapter does not support the requested message type.',
56
+ wechat_tool_error: 'The adapter tool could not execute this operation.',
57
+ };
58
+ const EXECUTOR_ERROR_SCHEMA = z.strictObject({
59
+ kind: z.string(),
60
+ message: z.string(),
61
+ code: z.union([z.string(), z.number()]).optional(),
62
+ failType: z.number().int().optional(),
63
+ });
64
+ const EXECUTOR_RECEIPT_SCHEMA = z.strictObject({
65
+ status: z.enum(['accepted', 'failed', 'uncertain']),
66
+ attemptId: z.string().regex(ATTEMPT_ID),
67
+ sendIndex: z.number().int().min(0).max(999),
68
+ type: z.enum(SEND_TYPES),
69
+ providerMessageId: z.string().max(512),
70
+ error: EXECUTOR_ERROR_SCHEMA.optional(),
71
+ });
72
+ const ERROR_OUTPUT_SCHEMA = z.strictObject({
73
+ kind: z.enum(ERROR_KINDS),
74
+ message: z.string(),
75
+ code: z.union([
76
+ z.string().regex(SAFE_CODE),
77
+ z.number().finite(),
78
+ ]).optional(),
79
+ failType: z.number().int().nonnegative().optional(),
80
+ });
81
+ const RECEIPT_SCHEMA = z.strictObject({
82
+ status: z.enum(['accepted', 'failed', 'uncertain']),
83
+ attemptId: z.string().max(128),
84
+ sendIndex: z.number().int().min(-1).max(999),
85
+ type: z.enum(SEND_TYPES),
86
+ providerMessageId: z.string().max(512),
87
+ error: ERROR_OUTPUT_SCHEMA.optional(),
88
+ });
89
+ function response(result, isError = false) {
90
+ return {
91
+ ...(isError ? { isError: true } : {}),
92
+ content: [{ type: 'text', text: JSON.stringify(result) }],
93
+ structuredContent: result,
94
+ };
95
+ }
96
+ function safeErrorKind(status, kind, failType = 0) {
97
+ if (status === 'failed' && failType === 13)
98
+ return 'sensitive_content';
99
+ if (ERROR_KINDS.includes(kind || '')) {
100
+ return kind;
101
+ }
102
+ return status === 'uncertain' ? 'uncertain_result' : 'wechat_delivery_failed';
103
+ }
104
+ function safeCode(value) {
105
+ if (typeof value === 'number')
106
+ return Number.isFinite(value) ? value : undefined;
107
+ return typeof value === 'string' && SAFE_CODE.test(value) ? value : undefined;
108
+ }
109
+ function safeReceipt(value) {
110
+ const parsed = EXECUTOR_RECEIPT_SCHEMA.parse(value);
111
+ const { error, ...base } = parsed;
112
+ if (base.status === 'accepted')
113
+ return base;
114
+ const failType = Number.isInteger(error?.failType) && Number(error?.failType) >= 0
115
+ ? Number(error?.failType)
116
+ : 0;
117
+ const kind = safeErrorKind(base.status, error?.kind, failType);
118
+ const code = safeCode(error?.code);
119
+ return {
120
+ ...base,
121
+ error: {
122
+ kind,
123
+ message: SAFE_ERROR_MESSAGES[kind],
124
+ ...(code !== undefined ? { code } : {}),
125
+ ...(failType ? { failType } : {}),
126
+ },
127
+ };
128
+ }
129
+ function toolFailure(error, type) {
130
+ const errorCode = error && typeof error === 'object' && 'code' in error
131
+ ? String(error.code || '')
132
+ : '';
133
+ const kind = ERROR_KINDS.includes(errorCode)
134
+ ? errorCode
135
+ : 'wechat_tool_error';
136
+ const result = {
137
+ status: 'failed',
138
+ attemptId: '',
139
+ sendIndex: -1,
140
+ type,
141
+ providerMessageId: '',
142
+ error: { kind, message: SAFE_ERROR_MESSAGES[kind] },
143
+ };
144
+ return response(result, true);
145
+ }
146
+ function toolResult(result) {
147
+ const safe = safeReceipt(result);
148
+ return response({ ...safe }, safe.status === 'failed');
149
+ }
150
+ function toolType(name) {
151
+ if (name === 'offer_weixin_bot_channel' || name === 'send_image')
152
+ return 'image';
153
+ const type = name.replace(/^send_/u, '');
154
+ return SEND_TYPES.includes(type)
155
+ ? type
156
+ : 'text';
157
+ }
158
+ function register(server, executor, name, definition) {
159
+ server.registerTool(name, {
160
+ ...definition,
161
+ outputSchema: RECEIPT_SCHEMA,
162
+ annotations: TOOL_ANNOTATIONS,
163
+ }, async (input) => {
164
+ try {
165
+ return toolResult(await executor.execute(name, input));
166
+ }
167
+ catch (error) {
168
+ return toolFailure(error, toolType(name));
169
+ }
170
+ });
171
+ }
172
+ const SESSION = z.string().regex(/^ws_[A-Za-z0-9_-]{32}$/u);
173
+ const TOOL_DEFINITIONS = [
174
+ [
175
+ 'offer_weixin_bot_channel',
176
+ 'Offer an optional independent Weixin iLink Bot channel by sending a login QR image to the bound conversation. Use only after the user clearly asks to switch or establish that channel.',
177
+ {},
178
+ ],
179
+ ['send_text', 'Send one WeChat text message.', { content: z.string().min(1) }],
180
+ ['send_image', 'Send one available image referenced by media:N or artifact:N.', {
181
+ mediaRef: z.string().regex(/^(?:media|artifact):(?:0|[1-9]\d?)$/u),
182
+ }],
183
+ ['send_link', 'Send one native WeChat link card.', {
184
+ title: z.string().min(1), description: z.string(), url: z.string().url(),
185
+ }],
186
+ ['send_miniprogram', 'Send one verified WeChat mini-program card.', {
187
+ appId: z.string().regex(/^wx[A-Za-z0-9]{16}$/u),
188
+ title: z.string().min(1), pagePath: z.string().min(1).max(1024),
189
+ sourceUrl: z.string().url(),
190
+ }],
191
+ ['send_location', 'Send one native WeChat location card.', {
192
+ name: z.string().min(1), address: z.string().min(1),
193
+ latitude: z.number().min(-90).max(90),
194
+ longitude: z.number().min(-180).max(180),
195
+ }],
196
+ ];
197
+ export function createWechatKfMcpServer(executor) {
198
+ const server = new McpServer({ name: 'wechat-kf-tools', version: KINTIO_VERSION }, {
199
+ instructions: 'These tools execute real WeChat KF channel API calls for the bound conversation. accepted means the API accepted the request, not confirmed client delivery. uncertain means the message may already have been sent and must not be repeated merely because the outcome is unknown. The server never selects another recipient or retries automatically.',
200
+ });
201
+ for (const [name, description, inputSchema] of TOOL_DEFINITIONS) {
202
+ register(server, executor, name, {
203
+ description,
204
+ inputSchema: { session: SESSION, ...inputSchema },
205
+ });
206
+ }
207
+ return server;
208
+ }
@@ -0,0 +1,89 @@
1
+ import { bodyLimit } from 'hono/body-limit';
2
+ import { extractXmlTag } from '../lib/xml.js';
3
+ const MAX_BODY_BYTES = 1024 * 1024;
4
+ function getCallbackParameters(context, includeEchoString) {
5
+ const parameters = {
6
+ signature: context.req.query('msg_signature') || '',
7
+ timestamp: context.req.query('timestamp') || '',
8
+ nonce: context.req.query('nonce') || '',
9
+ encrypted: '',
10
+ };
11
+ if (includeEchoString) {
12
+ parameters.encrypted = context.req.query('echostr') || '';
13
+ }
14
+ return parameters;
15
+ }
16
+ function hasAuthenticationQuery(context) {
17
+ return (context.req.query('msg_signature') !== undefined ||
18
+ context.req.query('echostr') !== undefined);
19
+ }
20
+ export function registerWecomRoutes(app, { wecomCrypto, logger, messageProcessor, }) {
21
+ app.get('/', (context) => {
22
+ if (!hasAuthenticationQuery(context)) {
23
+ return context.text('hello world');
24
+ }
25
+ const parameters = getCallbackParameters(context, true);
26
+ const { signature, timestamp, nonce, encrypted } = parameters;
27
+ if (!signature || !timestamp || !nonce || !encrypted) {
28
+ return context.text('missing callback parameters', 400);
29
+ }
30
+ if (!wecomCrypto.verifySignature(signature, timestamp, nonce, encrypted)) {
31
+ return context.text('invalid signature', 403);
32
+ }
33
+ try {
34
+ const { message } = wecomCrypto.decryptMessage(encrypted);
35
+ logger.info('[wecom] callback URL verification succeeded');
36
+ return context.text(message);
37
+ }
38
+ catch (error) {
39
+ logger.error(`[wecom] callback URL verification failed: ${error instanceof Error ? error.message : String(error)}`);
40
+ return context.text('invalid encrypted payload', 400);
41
+ }
42
+ });
43
+ app.post('/', bodyLimit({
44
+ maxSize: MAX_BODY_BYTES,
45
+ onError: (context) => context.text('request body is too large', 413),
46
+ }), async (context) => {
47
+ const parameters = getCallbackParameters(context, false);
48
+ try {
49
+ const body = await context.req.text();
50
+ const encrypted = extractXmlTag(body, 'Encrypt');
51
+ if (!parameters.signature ||
52
+ !parameters.timestamp ||
53
+ !parameters.nonce ||
54
+ !encrypted) {
55
+ return context.text('invalid callback request', 400);
56
+ }
57
+ if (!wecomCrypto.verifySignature(parameters.signature, parameters.timestamp, parameters.nonce, encrypted)) {
58
+ return context.text('invalid signature', 403);
59
+ }
60
+ const { message } = wecomCrypto.decryptMessage(encrypted);
61
+ const event = extractXmlTag(message, 'Event') || 'unknown';
62
+ const openKfId = extractXmlTag(message, 'OpenKfId') || 'unknown';
63
+ const callbackToken = extractXmlTag(message, 'Token');
64
+ logger.info(`[wecom] accepted callback event=${event}`);
65
+ if (event === 'kf_msg_or_event' && messageProcessor) {
66
+ if (callbackToken && openKfId !== 'unknown') {
67
+ let accepted = false;
68
+ try {
69
+ accepted = messageProcessor.enqueue({ callbackToken, openKfId });
70
+ }
71
+ catch (error) {
72
+ logger.error(`[wecom] callback sync registration failed: ${error instanceof Error ? error.message : String(error)}`);
73
+ return context.text('service unavailable', 503);
74
+ }
75
+ if (!accepted)
76
+ return context.text('service unavailable', 503);
77
+ }
78
+ else {
79
+ logger.warn?.('[wecom] callback did not contain Token and OpenKfId; message sync skipped');
80
+ }
81
+ }
82
+ return context.text('success');
83
+ }
84
+ catch (error) {
85
+ logger.error(`[wecom] callback processing failed: ${error instanceof Error ? error.message : String(error)}`);
86
+ return context.text('invalid callback request', 400);
87
+ }
88
+ });
89
+ }