@24klynx/channels 0.1.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/dist/index.d.mts +145 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +395 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +28 -0
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { Message } from "@lynx/core";
|
|
2
|
+
|
|
3
|
+
//#region src/types.d.ts
|
|
4
|
+
/** What a channel can do. */
|
|
5
|
+
interface ChannelCapabilities {
|
|
6
|
+
/** Text messages. */
|
|
7
|
+
text: boolean;
|
|
8
|
+
/** Images / files. */
|
|
9
|
+
media: boolean;
|
|
10
|
+
/** Interactive cards / buttons. */
|
|
11
|
+
interactive: boolean;
|
|
12
|
+
/** Voice messages. */
|
|
13
|
+
voice: boolean;
|
|
14
|
+
/** Supports message reactions (π, π, etc.). */
|
|
15
|
+
reactions: boolean;
|
|
16
|
+
/** Supports message editing after sending. */
|
|
17
|
+
edit: boolean;
|
|
18
|
+
/** Supports message recall / delete. */
|
|
19
|
+
recall: boolean;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* A single messaging channel adapter.
|
|
23
|
+
*
|
|
24
|
+
* Structural typing β any object with these methods is a valid adapter.
|
|
25
|
+
* No `implements` keyword needed.
|
|
26
|
+
*/
|
|
27
|
+
interface ChannelAdapter {
|
|
28
|
+
/** Unique channel id, e.g. "feishu" or "qq". */
|
|
29
|
+
readonly channelId: string;
|
|
30
|
+
/** Static capability declaration. */
|
|
31
|
+
readonly capabilities: ChannelCapabilities;
|
|
32
|
+
/**
|
|
33
|
+
* Initialise the adapter (open connections, verify credentials, etc.).
|
|
34
|
+
* Called once at startup.
|
|
35
|
+
*/
|
|
36
|
+
init(): Promise<void>;
|
|
37
|
+
/**
|
|
38
|
+
* Start listening for incoming messages.
|
|
39
|
+
*
|
|
40
|
+
* The callback fires for each incoming message. Return a cleanup
|
|
41
|
+
* function that stops listening.
|
|
42
|
+
*/
|
|
43
|
+
onMessage(handler: (message: Message) => void): () => void;
|
|
44
|
+
/**
|
|
45
|
+
* Send a message through this channel.
|
|
46
|
+
*
|
|
47
|
+
* Returns the platformβassigned message id.
|
|
48
|
+
*/
|
|
49
|
+
sendMessage(message: Message): Promise<string>;
|
|
50
|
+
/**
|
|
51
|
+
* Edit a previously sent message.
|
|
52
|
+
* Throws {@link ChannelError} if the channel doesn't support editing.
|
|
53
|
+
*/
|
|
54
|
+
editMessage?(platformMessageId: string, newContent: string): Promise<void>;
|
|
55
|
+
/**
|
|
56
|
+
* Clean up resources (close connections, abort pending requests).
|
|
57
|
+
*/
|
|
58
|
+
destroy(): Promise<void>;
|
|
59
|
+
}
|
|
60
|
+
/** Connection state for a live channel. */
|
|
61
|
+
type ChannelState = {
|
|
62
|
+
status: "disconnected";
|
|
63
|
+
} | {
|
|
64
|
+
status: "connecting";
|
|
65
|
+
} | {
|
|
66
|
+
status: "connected";
|
|
67
|
+
connectedAt: number;
|
|
68
|
+
} | {
|
|
69
|
+
status: "error";
|
|
70
|
+
message: string;
|
|
71
|
+
retryable: boolean;
|
|
72
|
+
};
|
|
73
|
+
//#endregion
|
|
74
|
+
//#region src/contracts.d.ts
|
|
75
|
+
/**
|
|
76
|
+
* Verify that a channel adapter fulfills its capability contract.
|
|
77
|
+
*
|
|
78
|
+
* Throws {@link ChannelError} if the adapter claims a capability
|
|
79
|
+
* but doesn't implement the corresponding method.
|
|
80
|
+
*/
|
|
81
|
+
declare function validateCapabilities(adapter: ChannelAdapter): void;
|
|
82
|
+
/**
|
|
83
|
+
* Return a humanβreadable summary of what a channel supports.
|
|
84
|
+
*/
|
|
85
|
+
declare function describeCapabilities(adapter: ChannelAdapter): string;
|
|
86
|
+
//#endregion
|
|
87
|
+
//#region src/live.d.ts
|
|
88
|
+
interface LiveChannel {
|
|
89
|
+
/** The current connection state. */
|
|
90
|
+
readonly state: ChannelState;
|
|
91
|
+
/** Transition to a new state. */
|
|
92
|
+
transition(next: ChannelState): void;
|
|
93
|
+
/** Register a listener for state changes. */
|
|
94
|
+
onChange(handler: (prev: ChannelState, next: ChannelState) => void): () => void;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Create a live channel state machine starting from "disconnected".
|
|
98
|
+
*/
|
|
99
|
+
declare function createLiveChannel(): LiveChannel;
|
|
100
|
+
//#endregion
|
|
101
|
+
//#region src/channels/feishu.d.ts
|
|
102
|
+
interface FeishuConfig {
|
|
103
|
+
/** ι£δΉ¦εΊη¨ App ID. */
|
|
104
|
+
appId: string;
|
|
105
|
+
/** ι£δΉ¦εΊη¨ App Secret. */
|
|
106
|
+
appSecret: string;
|
|
107
|
+
/** Webhook verification token (for HTTP mode, not used in WS mode). */
|
|
108
|
+
verificationToken?: string;
|
|
109
|
+
/** API base URL (defaults to https://open.feishu.cn). */
|
|
110
|
+
baseUrl?: string;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Create a ι£δΉ¦ channel adapter.
|
|
114
|
+
*
|
|
115
|
+
* Sending uses the REST API. Receiving uses a WebSocket longβconnection
|
|
116
|
+
* established during init(). Incoming messages are parsed and dispatched
|
|
117
|
+
* to registered onMessage handlers.
|
|
118
|
+
*/
|
|
119
|
+
declare function createFeishuAdapter(config: FeishuConfig): ChannelAdapter;
|
|
120
|
+
//#endregion
|
|
121
|
+
//#region src/registry.d.ts
|
|
122
|
+
/** Lightweight registry for channel adapters. */
|
|
123
|
+
interface ChannelRegistry {
|
|
124
|
+
/** Register a channel adapter. Replaces any existing adapter with the same ID. */
|
|
125
|
+
register(adapter: ChannelAdapter): void;
|
|
126
|
+
/** Remove a channel by ID. Calls adapter.destroy() first. */
|
|
127
|
+
unregister(channelId: string): Promise<void>;
|
|
128
|
+
/** Get an adapter by ID. */
|
|
129
|
+
get(channelId: string): ChannelAdapter | undefined;
|
|
130
|
+
/** Initialize all registered adapters. */
|
|
131
|
+
initAll(): Promise<void>;
|
|
132
|
+
/** Destroy all registered adapters. */
|
|
133
|
+
destroyAll(): Promise<void>;
|
|
134
|
+
/** Set the handler for all incoming messages from any channel. */
|
|
135
|
+
onMessage(handler: (msg: Message) => void): void;
|
|
136
|
+
/** List IDs of all registered adapters. */
|
|
137
|
+
list(): string[];
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Create a channel registry for managing multiple channel adapters.
|
|
141
|
+
*/
|
|
142
|
+
declare function createChannelRegistry(): ChannelRegistry;
|
|
143
|
+
//#endregion
|
|
144
|
+
export { type ChannelAdapter, type ChannelCapabilities, type ChannelRegistry, type ChannelState, type FeishuConfig, type LiveChannel, createChannelRegistry, createFeishuAdapter, createLiveChannel, describeCapabilities, validateCapabilities };
|
|
145
|
+
//# sourceMappingURL=index.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/types.ts","../src/contracts.ts","../src/live.ts","../src/channels/feishu.ts","../src/registry.ts"],"mappings":";;;;UAaiB,mBAAA;EAQf;EANA,IAAA;EAUA;EARA,KAAA;EAUM;EARN,WAAA;EAmBe;EAjBf,KAAA;;EAEA,SAAA;EA0BQ;EAxBR,IAAA;EAuCqB;EArCrB,MAAA;AAAA;;;;;;;UAWe,cAAA;EAWP;EAAA,SATC,SAAA;EAiBoB;EAAA,SAdpB,YAAA,EAAc,mBAAA;EAcb;;;;EARV,IAAA,IAAQ,OAAA;EAqBR;;;;;;EAbA,SAAA,CAAU,OAAA,GAAU,OAAA,EAAS,OAAA;EAkBX;AAMpB;;;;EAjBE,WAAA,CAAY,OAAA,EAAS,OAAA,GAAU,OAAA;EAmB3B;;;;EAbJ,WAAA,EAAa,iBAAA,UAA2B,UAAA,WAAqB,OAAA;EAevB;;AAAS;EAV/C,OAAA,IAAW,OAAA;AAAA;;KAMD,YAAA;EACN,MAAA;AAAA;EACA,MAAA;AAAA;EACA,MAAA;EAAqB,WAAA;AAAA;EACrB,MAAA;EAAiB,OAAA;EAAiB,SAAA;AAAA;;;;;;;;;iBClExB,oBAAA,CAAqB,OAAuB,EAAd,cAAc;ADQpD;AAWR;;AAXQ,iBCMQ,oBAAA,CAAqB,OAAuB,EAAd,cAAc;;;UCnB3C,WAAA;EFSf;EAAA,SEPS,KAAA,EAAO,YAAA;EFWhB;EETA,UAAA,CAAW,IAAA,EAAM,YAAA;EFSX;EEPN,QAAA,CAAS,OAAA,GAAU,IAAA,EAAM,YAAA,EAAc,IAAA,EAAM,YAAA;AAAA;;;;iBAQ/B,iBAAA,IAAqB,WAAW;;;UCP/B,YAAA;EHiBA;EGff,KAAA;;EAEA,SAAA;EHwBQ;EGtBR,iBAAA;EHqCqB;EGnCrB,OAAA;AAAA;;;;;;;;iBAuEc,mBAAA,CAAoB,MAAA,EAAQ,YAAA,GAAe,cAAc;;;;UCxFxD,eAAA;EJWf;EITA,QAAA,CAAS,OAAA,EAAS,cAAA;EJalB;EIVA,UAAA,CAAW,SAAA,WAAoB,OAAA;EJUzB;EIPN,GAAA,CAAI,SAAA,WAAoB,cAAA;EJkBK;EIf7B,OAAA,IAAW,OAAA;EJoBY;EIjBvB,UAAA,IAAc,OAAA;EJ+Be;EI5B7B,SAAA,CAAU,OAAA,GAAU,GAAA,EAAK,OAAA;EJmCM;EIhC/B,IAAA;AAAA;;;;iBAMc,qBAAA,IAAyB,eAAe"}
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import { ChannelError, asMessageId } from "@lynx/core";
|
|
2
|
+
//#region src/contracts.ts
|
|
3
|
+
/**
|
|
4
|
+
* Channel capability validation.
|
|
5
|
+
*
|
|
6
|
+
* Before using a channel adapter, the runtime verifies that the adapter
|
|
7
|
+
* actually provides the methods it claims to have in its capability
|
|
8
|
+
* declaration.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Verify that a channel adapter fulfills its capability contract.
|
|
12
|
+
*
|
|
13
|
+
* Throws {@link ChannelError} if the adapter claims a capability
|
|
14
|
+
* but doesn't implement the corresponding method.
|
|
15
|
+
*/
|
|
16
|
+
function validateCapabilities(adapter) {
|
|
17
|
+
if (adapter.capabilities.edit && typeof adapter.editMessage !== "function") throw new ChannelError(`Channel "${adapter.channelId}" claims edit capability but does not implement editMessage()`, {
|
|
18
|
+
category: "channel",
|
|
19
|
+
userVisible: false
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Return a humanβreadable summary of what a channel supports.
|
|
24
|
+
*/
|
|
25
|
+
function describeCapabilities(adapter) {
|
|
26
|
+
const cap = adapter.capabilities;
|
|
27
|
+
const flags = [];
|
|
28
|
+
if (cap.text) flags.push("text");
|
|
29
|
+
if (cap.media) flags.push("media");
|
|
30
|
+
if (cap.interactive) flags.push("interactive");
|
|
31
|
+
if (cap.voice) flags.push("voice");
|
|
32
|
+
if (cap.reactions) flags.push("reactions");
|
|
33
|
+
if (cap.edit) flags.push("edit");
|
|
34
|
+
if (cap.recall) flags.push("recall");
|
|
35
|
+
return `${adapter.channelId}: ${flags.join(", ") || "no capabilities"}`;
|
|
36
|
+
}
|
|
37
|
+
//#endregion
|
|
38
|
+
//#region src/live.ts
|
|
39
|
+
/**
|
|
40
|
+
* Create a live channel state machine starting from "disconnected".
|
|
41
|
+
*/
|
|
42
|
+
function createLiveChannel() {
|
|
43
|
+
let current = { status: "disconnected" };
|
|
44
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
45
|
+
return {
|
|
46
|
+
get state() {
|
|
47
|
+
return current;
|
|
48
|
+
},
|
|
49
|
+
transition(next) {
|
|
50
|
+
const prev = current;
|
|
51
|
+
current = next;
|
|
52
|
+
for (const listener of listeners) try {
|
|
53
|
+
listener(prev, next);
|
|
54
|
+
} catch {}
|
|
55
|
+
},
|
|
56
|
+
onChange(handler) {
|
|
57
|
+
listeners.add(handler);
|
|
58
|
+
return () => listeners.delete(handler);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
//#endregion
|
|
63
|
+
//#region src/channels/feishu.ts
|
|
64
|
+
/**
|
|
65
|
+
* ι£δΉ¦ (Feishu/Lark) channel adapter.
|
|
66
|
+
*
|
|
67
|
+
* Implements the {@link ChannelAdapter} interface for the ι£δΉ¦
|
|
68
|
+
* messaging platform. Uses the ι£δΉ¦ Open API for sending and
|
|
69
|
+
* WebSocket longβconnection for receiving events.
|
|
70
|
+
*
|
|
71
|
+
* Receiving flow:
|
|
72
|
+
* 1. init() β get tenant_access_token
|
|
73
|
+
* 2. POST /open-apis/ws/v1/connection β get WebSocket endpoint
|
|
74
|
+
* 3. Connect WebSocket β receive events
|
|
75
|
+
* 4. Parse im.message.receive_v1 β convert to Lynx Message
|
|
76
|
+
* 5. Dispatch to registered onMessage handlers
|
|
77
|
+
*/
|
|
78
|
+
const DEFAULT_BASE_URL = "https://open.feishu.cn";
|
|
79
|
+
/** Reconnect delay range in ms. */
|
|
80
|
+
const RECONNECT_MIN_MS = 1e3;
|
|
81
|
+
const RECONNECT_MAX_MS = 3e4;
|
|
82
|
+
/** WebSocket ping interval in ms. */
|
|
83
|
+
const PING_INTERVAL_MS = 3e4;
|
|
84
|
+
const FEISHU_CAPABILITIES = {
|
|
85
|
+
text: true,
|
|
86
|
+
media: true,
|
|
87
|
+
interactive: true,
|
|
88
|
+
voice: false,
|
|
89
|
+
reactions: false,
|
|
90
|
+
edit: true,
|
|
91
|
+
recall: true
|
|
92
|
+
};
|
|
93
|
+
/**
|
|
94
|
+
* Create a ι£δΉ¦ channel adapter.
|
|
95
|
+
*
|
|
96
|
+
* Sending uses the REST API. Receiving uses a WebSocket longβconnection
|
|
97
|
+
* established during init(). Incoming messages are parsed and dispatched
|
|
98
|
+
* to registered onMessage handlers.
|
|
99
|
+
*/
|
|
100
|
+
function createFeishuAdapter(config) {
|
|
101
|
+
const baseUrl = config.baseUrl ?? DEFAULT_BASE_URL;
|
|
102
|
+
let accessToken;
|
|
103
|
+
const messageHandlers = /* @__PURE__ */ new Set();
|
|
104
|
+
let ws = null;
|
|
105
|
+
let reconnectTimer = null;
|
|
106
|
+
let pingTimer = null;
|
|
107
|
+
let reconnectDelay = RECONNECT_MIN_MS;
|
|
108
|
+
let destroyed = false;
|
|
109
|
+
/** Establish the WebSocket connection and start receiving events. */
|
|
110
|
+
async function connectWs() {
|
|
111
|
+
if (destroyed || !accessToken) return;
|
|
112
|
+
try {
|
|
113
|
+
const connResp = await fetch(`${baseUrl}/open-apis/ws/v1/connection`, {
|
|
114
|
+
method: "POST",
|
|
115
|
+
headers: {
|
|
116
|
+
Authorization: `Bearer ${accessToken}`,
|
|
117
|
+
"Content-Type": "application/json"
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
if (!connResp.ok) throw new Error(`θ·ε WebSocket η«―ηΉε€±θ΄₯οΌHTTP ${connResp.status}`);
|
|
121
|
+
const connData = await connResp.json();
|
|
122
|
+
if (connData.code !== 0 || !connData.data?.endpoint) throw new Error(`θ·ε WebSocket η«―ηΉε€±θ΄₯οΌ${connData.msg ?? "ζ η«―ηΉ"}`);
|
|
123
|
+
ws = new WebSocket(connData.data.endpoint);
|
|
124
|
+
ws.addEventListener("open", () => {
|
|
125
|
+
reconnectDelay = RECONNECT_MIN_MS;
|
|
126
|
+
startPing();
|
|
127
|
+
});
|
|
128
|
+
ws.addEventListener("message", (event) => {
|
|
129
|
+
try {
|
|
130
|
+
const raw = typeof event.data === "string" ? event.data : "";
|
|
131
|
+
if (!raw) return;
|
|
132
|
+
handleWsMessage(JSON.parse(raw));
|
|
133
|
+
} catch {}
|
|
134
|
+
});
|
|
135
|
+
ws.addEventListener("close", () => {
|
|
136
|
+
stopPing();
|
|
137
|
+
ws = null;
|
|
138
|
+
if (!destroyed) scheduleReconnect();
|
|
139
|
+
});
|
|
140
|
+
ws.addEventListener("error", () => {});
|
|
141
|
+
} catch {
|
|
142
|
+
if (!destroyed) scheduleReconnect();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/** Process an incoming WebSocket message from Feishu. */
|
|
146
|
+
function handleWsMessage(msg) {
|
|
147
|
+
if (msg.type === "url_verification" && msg.challenge) {
|
|
148
|
+
ws?.send(JSON.stringify({
|
|
149
|
+
type: "url_verification",
|
|
150
|
+
challenge: msg.challenge
|
|
151
|
+
}));
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (msg.type === "event_callback" && msg.data) {
|
|
155
|
+
if (msg.data.header?.event_type === "im.message.receive_v1") {
|
|
156
|
+
const lynxMsg = convertToLynxMessage(msg.data.event);
|
|
157
|
+
if (lynxMsg) dispatchToHandlers(lynxMsg);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/** Dispatch a message to all registered handlers, catching errors per handler. */
|
|
162
|
+
function dispatchToHandlers(msg) {
|
|
163
|
+
for (const handler of messageHandlers) try {
|
|
164
|
+
handler(msg);
|
|
165
|
+
} catch {}
|
|
166
|
+
}
|
|
167
|
+
/** Convert a Feishu message event to a Lynx Message. */
|
|
168
|
+
function convertToLynxMessage(event) {
|
|
169
|
+
const msg = event.message;
|
|
170
|
+
if (!msg) return null;
|
|
171
|
+
let text = "";
|
|
172
|
+
if (msg.message_type === "text") try {
|
|
173
|
+
text = JSON.parse(msg.content).text ?? "";
|
|
174
|
+
} catch {
|
|
175
|
+
text = msg.content;
|
|
176
|
+
}
|
|
177
|
+
if (!text) return null;
|
|
178
|
+
return {
|
|
179
|
+
id: asMessageId(msg.message_id),
|
|
180
|
+
role: "user",
|
|
181
|
+
content: [{
|
|
182
|
+
type: "text",
|
|
183
|
+
text
|
|
184
|
+
}],
|
|
185
|
+
timestamp: Number(msg.create_time) * 1e3,
|
|
186
|
+
turnIndex: 0
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
/** Schedule a reconnection attempt with exponential backoff. */
|
|
190
|
+
function scheduleReconnect() {
|
|
191
|
+
if (destroyed || reconnectTimer) return;
|
|
192
|
+
reconnectTimer = setTimeout(() => {
|
|
193
|
+
reconnectTimer = null;
|
|
194
|
+
connectWs();
|
|
195
|
+
}, reconnectDelay);
|
|
196
|
+
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
|
|
197
|
+
}
|
|
198
|
+
/** Start the WebSocket ping keepalive. */
|
|
199
|
+
function startPing() {
|
|
200
|
+
stopPing();
|
|
201
|
+
pingTimer = setInterval(() => {
|
|
202
|
+
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "ping" }));
|
|
203
|
+
}, PING_INTERVAL_MS);
|
|
204
|
+
}
|
|
205
|
+
/** Stop the WebSocket ping keepalive. */
|
|
206
|
+
function stopPing() {
|
|
207
|
+
if (pingTimer) {
|
|
208
|
+
clearInterval(pingTimer);
|
|
209
|
+
pingTimer = null;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/** Tear down the WebSocket connection and all timers. */
|
|
213
|
+
function disconnectWs() {
|
|
214
|
+
if (reconnectTimer) {
|
|
215
|
+
clearTimeout(reconnectTimer);
|
|
216
|
+
reconnectTimer = null;
|
|
217
|
+
}
|
|
218
|
+
stopPing();
|
|
219
|
+
if (ws) {
|
|
220
|
+
ws.close(1e3, "adapter destroyed");
|
|
221
|
+
ws = null;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
channelId: "feishu",
|
|
226
|
+
capabilities: FEISHU_CAPABILITIES,
|
|
227
|
+
async init() {
|
|
228
|
+
if (!config.appId || !config.appSecret) throw new ChannelError("ι£δΉ¦ adapter requires appId and appSecret", {
|
|
229
|
+
category: "channel",
|
|
230
|
+
recoverable: false,
|
|
231
|
+
userVisible: true
|
|
232
|
+
});
|
|
233
|
+
try {
|
|
234
|
+
const response = await fetch(`${baseUrl}/open-apis/auth/v3/tenant_access_token/internal`, {
|
|
235
|
+
method: "POST",
|
|
236
|
+
headers: { "Content-Type": "application/json" },
|
|
237
|
+
body: JSON.stringify({
|
|
238
|
+
app_id: config.appId,
|
|
239
|
+
app_secret: config.appSecret
|
|
240
|
+
})
|
|
241
|
+
});
|
|
242
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
243
|
+
const data = await response.json();
|
|
244
|
+
if (!data.tenant_access_token) throw new Error("εεΊδΈζ tenant_access_token");
|
|
245
|
+
accessToken = data.tenant_access_token;
|
|
246
|
+
} catch (err) {
|
|
247
|
+
throw new ChannelError(`ι£δΉ¦ authentication failed: ${String(err)}`, {
|
|
248
|
+
category: "channel",
|
|
249
|
+
recoverable: true,
|
|
250
|
+
retryable: true
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
await connectWs();
|
|
254
|
+
},
|
|
255
|
+
onMessage(handler) {
|
|
256
|
+
messageHandlers.add(handler);
|
|
257
|
+
return () => messageHandlers.delete(handler);
|
|
258
|
+
},
|
|
259
|
+
async sendMessage(message) {
|
|
260
|
+
if (!accessToken) throw new ChannelError("ι£δΉ¦ιι
ε¨ζͺεε§ε β θ―·ε
θ°η¨ init()", {
|
|
261
|
+
category: "channel",
|
|
262
|
+
recoverable: true,
|
|
263
|
+
retryable: true
|
|
264
|
+
});
|
|
265
|
+
const text = extractTextContent(message);
|
|
266
|
+
try {
|
|
267
|
+
const response = await fetch(`${baseUrl}/open-apis/im/v1/messages?receive_id_type=open_id`, {
|
|
268
|
+
method: "POST",
|
|
269
|
+
headers: {
|
|
270
|
+
"Content-Type": "application/json",
|
|
271
|
+
Authorization: `Bearer ${accessToken}`
|
|
272
|
+
},
|
|
273
|
+
body: JSON.stringify({
|
|
274
|
+
receive_id: message.id,
|
|
275
|
+
msg_type: "text",
|
|
276
|
+
content: JSON.stringify({ text })
|
|
277
|
+
})
|
|
278
|
+
});
|
|
279
|
+
if (!response.ok) throw new ChannelError(`ι£δΉ¦ send failed: HTTP ${response.status}`, {
|
|
280
|
+
category: "channel",
|
|
281
|
+
recoverable: true,
|
|
282
|
+
retryable: true
|
|
283
|
+
});
|
|
284
|
+
return (await response.json()).data?.message_id ?? "unknown";
|
|
285
|
+
} catch (err) {
|
|
286
|
+
throw new ChannelError(`ι£δΉ¦ sendMessage error: ${String(err)}`, {
|
|
287
|
+
category: "channel",
|
|
288
|
+
recoverable: true,
|
|
289
|
+
retryable: true
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
},
|
|
293
|
+
/**
|
|
294
|
+
* Edit a previously sent message via the ι£δΉ¦ Open API.
|
|
295
|
+
*
|
|
296
|
+
* Uses PUT /open-apis/im/v1/messages/:message_id to update the
|
|
297
|
+
* text content of an existing message.
|
|
298
|
+
*/
|
|
299
|
+
async editMessage(platformMessageId, newContent) {
|
|
300
|
+
if (!accessToken) throw new ChannelError("ι£δΉ¦ιι
ε¨ζͺεε§ε β θ―·ε
θ°η¨ init()", {
|
|
301
|
+
category: "channel",
|
|
302
|
+
recoverable: true,
|
|
303
|
+
retryable: true
|
|
304
|
+
});
|
|
305
|
+
try {
|
|
306
|
+
const response = await fetch(`${baseUrl}/open-apis/im/v1/messages/${platformMessageId}`, {
|
|
307
|
+
method: "PUT",
|
|
308
|
+
headers: {
|
|
309
|
+
"Content-Type": "application/json",
|
|
310
|
+
Authorization: `Bearer ${accessToken}`
|
|
311
|
+
},
|
|
312
|
+
body: JSON.stringify({ content: JSON.stringify({ text: newContent }) })
|
|
313
|
+
});
|
|
314
|
+
if (!response.ok) throw new ChannelError(`ι£δΉ¦ editMessage failed: HTTP ${response.status}`, {
|
|
315
|
+
category: "channel",
|
|
316
|
+
recoverable: true,
|
|
317
|
+
retryable: true
|
|
318
|
+
});
|
|
319
|
+
} catch (err) {
|
|
320
|
+
if (err instanceof ChannelError) throw err;
|
|
321
|
+
throw new ChannelError(`ι£δΉ¦ editMessage error: ${String(err)}`, {
|
|
322
|
+
category: "channel",
|
|
323
|
+
recoverable: true,
|
|
324
|
+
retryable: true
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
},
|
|
328
|
+
async destroy() {
|
|
329
|
+
destroyed = true;
|
|
330
|
+
disconnectWs();
|
|
331
|
+
messageHandlers.clear();
|
|
332
|
+
accessToken = void 0;
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
function extractTextContent(message) {
|
|
337
|
+
for (const block of message.content) if (block.type === "text") return block.text;
|
|
338
|
+
return "";
|
|
339
|
+
}
|
|
340
|
+
//#endregion
|
|
341
|
+
//#region src/registry.ts
|
|
342
|
+
/**
|
|
343
|
+
* Create a channel registry for managing multiple channel adapters.
|
|
344
|
+
*/
|
|
345
|
+
function createChannelRegistry() {
|
|
346
|
+
const adapters = /* @__PURE__ */ new Map();
|
|
347
|
+
const cleanups = /* @__PURE__ */ new Map();
|
|
348
|
+
let messageHandler = null;
|
|
349
|
+
return {
|
|
350
|
+
register(adapter) {
|
|
351
|
+
const prevCleanup = cleanups.get(adapter.channelId);
|
|
352
|
+
if (prevCleanup) prevCleanup();
|
|
353
|
+
adapters.set(adapter.channelId, adapter);
|
|
354
|
+
const unsub = adapter.onMessage((msg) => {
|
|
355
|
+
if (messageHandler) try {
|
|
356
|
+
messageHandler(msg);
|
|
357
|
+
} catch {}
|
|
358
|
+
});
|
|
359
|
+
cleanups.set(adapter.channelId, unsub);
|
|
360
|
+
},
|
|
361
|
+
async unregister(channelId) {
|
|
362
|
+
const cleanup = cleanups.get(channelId);
|
|
363
|
+
if (cleanup) cleanup();
|
|
364
|
+
cleanups.delete(channelId);
|
|
365
|
+
const adapter = adapters.get(channelId);
|
|
366
|
+
adapters.delete(channelId);
|
|
367
|
+
if (adapter) try {
|
|
368
|
+
await adapter.destroy();
|
|
369
|
+
} catch {}
|
|
370
|
+
},
|
|
371
|
+
get(channelId) {
|
|
372
|
+
return adapters.get(channelId);
|
|
373
|
+
},
|
|
374
|
+
async initAll() {
|
|
375
|
+
const results = await Promise.allSettled([...adapters.values()].map((a) => a.init()));
|
|
376
|
+
for (const result of results) if (result.status === "rejected") process.stderr.write(`[lynx] Channel init failed: ${String(result.reason)}\n`);
|
|
377
|
+
},
|
|
378
|
+
async destroyAll() {
|
|
379
|
+
for (const cleanup of cleanups.values()) cleanup();
|
|
380
|
+
cleanups.clear();
|
|
381
|
+
await Promise.allSettled([...adapters.values()].map((a) => a.destroy()));
|
|
382
|
+
adapters.clear();
|
|
383
|
+
},
|
|
384
|
+
onMessage(handler) {
|
|
385
|
+
messageHandler = handler;
|
|
386
|
+
},
|
|
387
|
+
list() {
|
|
388
|
+
return [...adapters.keys()];
|
|
389
|
+
}
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
//#endregion
|
|
393
|
+
export { createChannelRegistry, createFeishuAdapter, createLiveChannel, describeCapabilities, validateCapabilities };
|
|
394
|
+
|
|
395
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/contracts.ts","../src/live.ts","../src/channels/feishu.ts","../src/registry.ts"],"sourcesContent":["/**\n * Channel capability validation.\n *\n * Before using a channel adapter, the runtime verifies that the adapter\n * actually provides the methods it claims to have in its capability\n * declaration.\n */\n\nimport { ChannelError } from \"@lynx/core\";\nimport type { ChannelAdapter } from \"./types.js\";\n\n// ββ Public API βββββββββββββββββββββββββββββββββββββ\n\n/**\n * Verify that a channel adapter fulfills its capability contract.\n *\n * Throws {@link ChannelError} if the adapter claims a capability\n * but doesn't implement the corresponding method.\n */\nexport function validateCapabilities(adapter: ChannelAdapter): void {\n const cap = adapter.capabilities;\n\n if (cap.edit && typeof adapter.editMessage !== \"function\") {\n throw new ChannelError(\n `Channel \"${adapter.channelId}\" claims edit capability but does not implement editMessage()`,\n { category: \"channel\", userVisible: false },\n );\n }\n}\n\n/**\n * Return a humanβreadable summary of what a channel supports.\n */\nexport function describeCapabilities(adapter: ChannelAdapter): string {\n const cap = adapter.capabilities;\n const flags: string[] = [];\n\n if (cap.text) flags.push(\"text\");\n if (cap.media) flags.push(\"media\");\n if (cap.interactive) flags.push(\"interactive\");\n if (cap.voice) flags.push(\"voice\");\n if (cap.reactions) flags.push(\"reactions\");\n if (cap.edit) flags.push(\"edit\");\n if (cap.recall) flags.push(\"recall\");\n\n return `${adapter.channelId}: ${flags.join(\", \") || \"no capabilities\"}`;\n}\n","/**\n * Live message state machine.\n *\n * Each channel adapter runs through connection states:\n * disconnected β connecting β connected (or error)\n *\n * The state machine tracks transitions and emits state change events\n * so the TUI can show connection status badges.\n */\n\nimport type { ChannelState } from \"./types.js\";\n\n// ββ Types ββββββββββββββββββββββββββββββββββββββββββ\n\nexport interface LiveChannel {\n /** The current connection state. */\n readonly state: ChannelState;\n /** Transition to a new state. */\n transition(next: ChannelState): void;\n /** Register a listener for state changes. */\n onChange(handler: (prev: ChannelState, next: ChannelState) => void): () => void;\n}\n\n// ββ Public API βββββββββββββββββββββββββββββββββββββ\n\n/**\n * Create a live channel state machine starting from \"disconnected\".\n */\nexport function createLiveChannel(): LiveChannel {\n let current: ChannelState = { status: \"disconnected\" };\n const listeners = new Set<(prev: ChannelState, next: ChannelState) => void>();\n\n const live: LiveChannel = {\n get state(): ChannelState {\n return current;\n },\n\n transition(next: ChannelState): void {\n const prev = current;\n current = next;\n for (const listener of listeners) {\n try {\n listener(prev, next);\n } catch {\n // Listener errors must not break the state machine\n }\n }\n },\n\n onChange(handler: (prev: ChannelState, next: ChannelState) => void): () => void {\n listeners.add(handler);\n return () => listeners.delete(handler);\n },\n };\n\n return live;\n}\n","/**\n * ι£δΉ¦ (Feishu/Lark) channel adapter.\n *\n * Implements the {@link ChannelAdapter} interface for the ι£δΉ¦\n * messaging platform. Uses the ι£δΉ¦ Open API for sending and\n * WebSocket longβconnection for receiving events.\n *\n * Receiving flow:\n * 1. init() β get tenant_access_token\n * 2. POST /open-apis/ws/v1/connection β get WebSocket endpoint\n * 3. Connect WebSocket β receive events\n * 4. Parse im.message.receive_v1 β convert to Lynx Message\n * 5. Dispatch to registered onMessage handlers\n */\n\nimport { ChannelError, asMessageId } from \"@lynx/core\";\nimport type { Message } from \"@lynx/core\";\nimport type { ChannelAdapter, ChannelCapabilities } from \"../types.js\";\n\n// ββ Configuration ββββββββββββββββββββββββββββββββββ\n\nexport interface FeishuConfig {\n /** ι£δΉ¦εΊη¨ App ID. */\n appId: string;\n /** ι£δΉ¦εΊη¨ App Secret. */\n appSecret: string;\n /** Webhook verification token (for HTTP mode, not used in WS mode). */\n verificationToken?: string;\n /** API base URL (defaults to https://open.feishu.cn). */\n baseUrl?: string;\n}\n\nconst DEFAULT_BASE_URL = \"https://open.feishu.cn\";\n\n/** Reconnect delay range in ms. */\nconst RECONNECT_MIN_MS = 1_000;\nconst RECONNECT_MAX_MS = 30_000;\n\n/** WebSocket ping interval in ms. */\nconst PING_INTERVAL_MS = 30_000;\n\n// ββ Capabilities βββββββββββββββββββββββββββββββββββ\n\nconst FEISHU_CAPABILITIES: ChannelCapabilities = {\n text: true,\n media: true,\n interactive: true,\n voice: false,\n reactions: false,\n edit: true,\n recall: true,\n};\n\n// ββ Feishu event types (subset) ββββββββββββββββββββ\n\ninterface FeishuHeader {\n event_id: string;\n event_type: string;\n tenant_key: string;\n app_id: string;\n}\n\ninterface FeishuEventEnvelope {\n schema: string;\n header: FeishuHeader;\n event: Record<string, unknown>;\n}\n\ninterface FeishuWsMessage {\n type: string;\n challenge?: string;\n data?: FeishuEventEnvelope;\n}\n\ninterface FeishuMessageEvent {\n sender?: {\n sender_id?: {\n open_id?: string;\n user_id?: string;\n };\n };\n message?: {\n message_id: string;\n chat_id: string;\n chat_type: string;\n create_time: string;\n message_type: string;\n content: string; // JSON-encoded string\n };\n}\n\n// ββ Adapter ββββββββββββββββββββββββββββββββββββββββ\n\n/**\n * Create a ι£δΉ¦ channel adapter.\n *\n * Sending uses the REST API. Receiving uses a WebSocket longβconnection\n * established during init(). Incoming messages are parsed and dispatched\n * to registered onMessage handlers.\n */\nexport function createFeishuAdapter(config: FeishuConfig): ChannelAdapter {\n const baseUrl = config.baseUrl ?? DEFAULT_BASE_URL;\n let accessToken: string | undefined;\n const messageHandlers = new Set<(msg: Message) => void>();\n\n // WebSocket state\n let ws: WebSocket | null = null;\n let reconnectTimer: ReturnType<typeof setTimeout> | null = null;\n let pingTimer: ReturnType<typeof setInterval> | null = null;\n let reconnectDelay = RECONNECT_MIN_MS;\n let destroyed = false;\n\n // ββ WebSocket connection βββββββββββββββββββββββββ\n\n /** Establish the WebSocket connection and start receiving events. */\n async function connectWs(): Promise<void> {\n if (destroyed || !accessToken) return;\n\n try {\n // 1. Get WebSocket endpoint from Feishu\n const connResp = await fetch(`${baseUrl}/open-apis/ws/v1/connection`, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${accessToken}`,\n \"Content-Type\": \"application/json\",\n },\n });\n\n if (!connResp.ok) {\n throw new Error(`θ·ε WebSocket η«―ηΉε€±θ΄₯οΌHTTP ${connResp.status}`);\n }\n\n const connData = (await connResp.json()) as {\n code: number;\n msg?: string;\n data?: { endpoint?: string; tcp_conn_id?: string };\n };\n\n if (connData.code !== 0 || !connData.data?.endpoint) {\n throw new Error(`θ·ε WebSocket η«―ηΉε€±θ΄₯οΌ${connData.msg ?? \"ζ η«―ηΉ\"}`);\n }\n\n // 2. Connect WebSocket\n ws = new WebSocket(connData.data.endpoint);\n\n ws.addEventListener(\"open\", () => {\n reconnectDelay = RECONNECT_MIN_MS; // reset backoff on successful connect\n startPing();\n });\n\n ws.addEventListener(\"message\", (event) => {\n try {\n const raw = typeof event.data === \"string\" ? event.data : \"\";\n if (!raw) return;\n const msg = JSON.parse(raw) as FeishuWsMessage;\n handleWsMessage(msg);\n } catch {\n // Malformed messages are logged and dropped\n }\n });\n\n ws.addEventListener(\"close\", () => {\n stopPing();\n ws = null;\n if (!destroyed) {\n scheduleReconnect();\n }\n });\n\n ws.addEventListener(\"error\", () => {\n // The \"close\" event will fire after \"error\", so reconnect is handled there\n });\n } catch {\n if (!destroyed) {\n scheduleReconnect();\n }\n }\n }\n\n /** Process an incoming WebSocket message from Feishu. */\n function handleWsMessage(msg: FeishuWsMessage): void {\n // URL verification challenge β respond inline\n if (msg.type === \"url_verification\" && msg.challenge) {\n ws?.send(JSON.stringify({ type: \"url_verification\", challenge: msg.challenge }));\n return;\n }\n\n // Event callback β parse and dispatch\n if (msg.type === \"event_callback\" && msg.data) {\n const eventType = msg.data.header?.event_type;\n if (eventType === \"im.message.receive_v1\") {\n const lynxMsg = convertToLynxMessage(msg.data.event as FeishuMessageEvent);\n if (lynxMsg) dispatchToHandlers(lynxMsg);\n }\n }\n }\n\n /** Dispatch a message to all registered handlers, catching errors per handler. */\n function dispatchToHandlers(msg: Message): void {\n for (const handler of messageHandlers) {\n try {\n handler(msg);\n } catch {\n // Handler errors must not break the dispatch loop\n }\n }\n }\n\n /** Convert a Feishu message event to a Lynx Message. */\n function convertToLynxMessage(event: FeishuMessageEvent): Message | null {\n const msg = event.message;\n if (!msg) return null;\n\n let text = \"\";\n if (msg.message_type === \"text\") {\n try {\n const content = JSON.parse(msg.content) as { text?: string };\n text = content.text ?? \"\";\n } catch {\n text = msg.content;\n }\n }\n\n if (!text) return null;\n\n return {\n id: asMessageId(msg.message_id),\n role: \"user\",\n content: [{ type: \"text\", text }],\n timestamp: Number(msg.create_time) * 1000,\n turnIndex: 0, // Caller is responsible for assigning turn index\n };\n }\n\n /** Schedule a reconnection attempt with exponential backoff. */\n function scheduleReconnect(): void {\n if (destroyed || reconnectTimer) return;\n\n reconnectTimer = setTimeout(() => {\n reconnectTimer = null;\n connectWs();\n }, reconnectDelay);\n\n // Exponential backoff with jitter\n reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);\n }\n\n /** Start the WebSocket ping keepalive. */\n function startPing(): void {\n stopPing();\n pingTimer = setInterval(() => {\n if (ws?.readyState === WebSocket.OPEN) {\n ws.send(JSON.stringify({ type: \"ping\" }));\n }\n }, PING_INTERVAL_MS);\n }\n\n /** Stop the WebSocket ping keepalive. */\n function stopPing(): void {\n if (pingTimer) {\n clearInterval(pingTimer);\n pingTimer = null;\n }\n }\n\n /** Tear down the WebSocket connection and all timers. */\n function disconnectWs(): void {\n if (reconnectTimer) {\n clearTimeout(reconnectTimer);\n reconnectTimer = null;\n }\n stopPing();\n if (ws) {\n ws.close(1000, \"adapter destroyed\");\n ws = null;\n }\n }\n\n // ββ Adapter object βββββββββββββββββββββββββββββββ\n\n const adapter: ChannelAdapter = {\n channelId: \"feishu\",\n capabilities: FEISHU_CAPABILITIES,\n\n async init(): Promise<void> {\n if (!config.appId || !config.appSecret) {\n throw new ChannelError(\"ι£δΉ¦ adapter requires appId and appSecret\", {\n category: \"channel\",\n recoverable: false,\n userVisible: true,\n });\n }\n\n // Fetch tenant access token\n try {\n const response = await fetch(`${baseUrl}/open-apis/auth/v3/tenant_access_token/internal`, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({\n app_id: config.appId,\n app_secret: config.appSecret,\n }),\n });\n\n if (!response.ok) {\n throw new Error(`HTTP ${response.status}`);\n }\n\n const data = (await response.json()) as { tenant_access_token?: string };\n if (!data.tenant_access_token) {\n throw new Error(\"εεΊδΈζ tenant_access_token\");\n }\n\n accessToken = data.tenant_access_token;\n } catch (err) {\n throw new ChannelError(`ι£δΉ¦ authentication failed: ${String(err)}`, {\n category: \"channel\",\n recoverable: true,\n retryable: true,\n });\n }\n\n // Start WebSocket receiving\n await connectWs();\n },\n\n onMessage(handler: (msg: Message) => void): () => void {\n messageHandlers.add(handler);\n return () => messageHandlers.delete(handler);\n },\n\n async sendMessage(message: Message): Promise<string> {\n if (!accessToken) {\n throw new ChannelError(\"ι£δΉ¦ιι
ε¨ζͺεε§ε β θ―·ε
θ°η¨ init()\", {\n category: \"channel\",\n recoverable: true,\n retryable: true,\n });\n }\n\n const text = extractTextContent(message);\n\n try {\n const response = await fetch(\n `${baseUrl}/open-apis/im/v1/messages?receive_id_type=open_id`,\n {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${accessToken}`,\n },\n body: JSON.stringify({\n receive_id: message.id, // In production, resolve real open_id\n msg_type: \"text\",\n content: JSON.stringify({ text }),\n }),\n },\n );\n\n if (!response.ok) {\n throw new ChannelError(`ι£δΉ¦ send failed: HTTP ${response.status}`, {\n category: \"channel\",\n recoverable: true,\n retryable: true,\n });\n }\n\n const data = (await response.json()) as { data?: { message_id?: string } };\n return data.data?.message_id ?? \"unknown\";\n } catch (err) {\n throw new ChannelError(`ι£δΉ¦ sendMessage error: ${String(err)}`, {\n category: \"channel\",\n recoverable: true,\n retryable: true,\n });\n }\n },\n\n /**\n * Edit a previously sent message via the ι£δΉ¦ Open API.\n *\n * Uses PUT /open-apis/im/v1/messages/:message_id to update the\n * text content of an existing message.\n */\n async editMessage(platformMessageId: string, newContent: string): Promise<void> {\n if (!accessToken) {\n throw new ChannelError(\"ι£δΉ¦ιι
ε¨ζͺεε§ε β θ―·ε
θ°η¨ init()\", {\n category: \"channel\",\n recoverable: true,\n retryable: true,\n });\n }\n\n try {\n const response = await fetch(`${baseUrl}/open-apis/im/v1/messages/${platformMessageId}`, {\n method: \"PUT\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${accessToken}`,\n },\n body: JSON.stringify({\n content: JSON.stringify({ text: newContent }),\n }),\n });\n\n if (!response.ok) {\n throw new ChannelError(`ι£δΉ¦ editMessage failed: HTTP ${response.status}`, {\n category: \"channel\",\n recoverable: true,\n retryable: true,\n });\n }\n } catch (err) {\n if (err instanceof ChannelError) throw err;\n throw new ChannelError(`ι£δΉ¦ editMessage error: ${String(err)}`, {\n category: \"channel\",\n recoverable: true,\n retryable: true,\n });\n }\n },\n\n async destroy(): Promise<void> {\n destroyed = true;\n disconnectWs();\n messageHandlers.clear();\n accessToken = undefined;\n },\n };\n\n return adapter;\n}\n\n// ββ Helpers ββββββββββββββββββββββββββββββββββββββββ\n\nfunction extractTextContent(message: Message): string {\n for (const block of message.content) {\n if (block.type === \"text\") {\n return block.text;\n }\n }\n return \"\";\n}\n","/**\n * ChannelRegistry β manages multiple {@link ChannelAdapter} instances.\n *\n * Each adapter is registered with a unique channel ID. The registry\n * handles init/destroy lifecycle and routes incoming messages to\n * a single onMessage handler (wired to the agent engine).\n */\n\nimport type { ChannelAdapter } from \"./types.js\";\nimport type { Message } from \"@lynx/core\";\n\n/** Lightweight registry for channel adapters. */\nexport interface ChannelRegistry {\n /** Register a channel adapter. Replaces any existing adapter with the same ID. */\n register(adapter: ChannelAdapter): void;\n\n /** Remove a channel by ID. Calls adapter.destroy() first. */\n unregister(channelId: string): Promise<void>;\n\n /** Get an adapter by ID. */\n get(channelId: string): ChannelAdapter | undefined;\n\n /** Initialize all registered adapters. */\n initAll(): Promise<void>;\n\n /** Destroy all registered adapters. */\n destroyAll(): Promise<void>;\n\n /** Set the handler for all incoming messages from any channel. */\n onMessage(handler: (msg: Message) => void): void;\n\n /** List IDs of all registered adapters. */\n list(): string[];\n}\n\n/**\n * Create a channel registry for managing multiple channel adapters.\n */\nexport function createChannelRegistry(): ChannelRegistry {\n const adapters = new Map<string, ChannelAdapter>();\n const cleanups = new Map<string, () => void>();\n let messageHandler: ((msg: Message) => void) | null = null;\n\n return {\n register(adapter: ChannelAdapter): void {\n // Clean up previous adapter if replacing\n const prevCleanup = cleanups.get(adapter.channelId);\n if (prevCleanup) prevCleanup();\n\n adapters.set(adapter.channelId, adapter);\n\n // Wire incoming messages\n const unsub = adapter.onMessage((msg) => {\n if (messageHandler) {\n try {\n messageHandler(msg);\n } catch {\n /* don't break dispatch */\n }\n }\n });\n cleanups.set(adapter.channelId, unsub);\n },\n\n async unregister(channelId: string): Promise<void> {\n const cleanup = cleanups.get(channelId);\n if (cleanup) cleanup();\n cleanups.delete(channelId);\n\n const adapter = adapters.get(channelId);\n adapters.delete(channelId);\n if (adapter) {\n try {\n await adapter.destroy();\n } catch {\n /* best effort */\n }\n }\n },\n\n get(channelId: string): ChannelAdapter | undefined {\n return adapters.get(channelId);\n },\n\n async initAll(): Promise<void> {\n const results = await Promise.allSettled([...adapters.values()].map((a) => a.init()));\n for (const result of results) {\n if (result.status === \"rejected\") {\n // Log but don't block β one channel failure shouldn't crash all\n process.stderr.write(`[lynx] Channel init failed: ${String(result.reason)}\\n`);\n }\n }\n },\n\n async destroyAll(): Promise<void> {\n for (const cleanup of cleanups.values()) cleanup();\n cleanups.clear();\n await Promise.allSettled([...adapters.values()].map((a) => a.destroy()));\n adapters.clear();\n },\n\n onMessage(handler: (msg: Message) => void): void {\n messageHandler = handler;\n },\n\n list(): string[] {\n return [...adapters.keys()];\n },\n };\n}\n"],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@24klynx/channels",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Message channel adapters β QQ, Feishu, WeChat",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.mjs",
|
|
7
|
+
"types": "./dist/index.d.mts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.mjs",
|
|
11
|
+
"types": "./dist/index.d.mts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@24klynx/core": "0.1.0"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist"
|
|
19
|
+
],
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "tsdown --config-loader tsx",
|
|
25
|
+
"test": "vitest run --passWithNoTests",
|
|
26
|
+
"typecheck": "tsgo --noEmit"
|
|
27
|
+
}
|
|
28
|
+
}
|