@linxiraos/pi-channels 1.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/CHANGELOG.md +19 -0
- package/README.md +8 -0
- package/package.json +64 -0
- package/src/channel.ts +23 -0
- package/src/feishu.ts +200 -0
- package/src/host.ts +138 -0
- package/src/index.ts +246 -0
- package/src/telegram.ts +130 -0
- package/src/types.ts +70 -0
- package/src/wechat.ts +570 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- Published tarballs now carry real dependency versions instead of Bun's `catalog:` protocol (1.1.0 installs failed with "Unsupported URL Type catalog:").
|
|
8
|
+
|
|
9
|
+
## [1.1.0] - 2026-08-25
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- 同步上游 OMP v18.0.3 / v18.0.4(内部运行时与构建改进,无独立用户可见变更)。
|
|
14
|
+
|
|
15
|
+
## [1.0.11] - 2026-08-22
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- IM channel adapters extracted from @linxiraos/zeta: WeChat (ClawBot / iLink), Feishu / Lark, and Telegram channels plus the `ChannelHost` coordinator bridge, session router, workspace router, IM control, plan approval, and plan image helpers. The channel runtime now lives in its own publishable package.
|
package/README.md
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# @linxiraos/pi-channels
|
|
2
|
+
|
|
3
|
+
IM channel adapters for Zeta: WeChat (ClawBot / iLink), Feishu / Lark, and
|
|
4
|
+
Telegram, plus the `ChannelHost` coordinator bridge, session router, workspace
|
|
5
|
+
router, IM control, plan-approval, and plan-image helpers.
|
|
6
|
+
|
|
7
|
+
The channel runtime is embedded in `zeta serve`; the CLI never imports this
|
|
8
|
+
package directly (no channel listeners exist outside a `ZetaServer`).
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@linxiraos/pi-channels",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "IM channel adapters for Zeta (WeChat / Feishu / Telegram)",
|
|
6
|
+
"homepage": "https://linxira-os.github.io/zeta/",
|
|
7
|
+
"author": "Can Boluk",
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/Linxira-OS/linxira-zeta.git",
|
|
12
|
+
"directory": "packages/channels"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/Linxira-OS/linxira-zeta/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"wechat",
|
|
19
|
+
"feishu",
|
|
20
|
+
"telegram",
|
|
21
|
+
"channels",
|
|
22
|
+
"im"
|
|
23
|
+
],
|
|
24
|
+
"main": "./src/index.ts",
|
|
25
|
+
"types": "./src/index.ts",
|
|
26
|
+
"scripts": {
|
|
27
|
+
"check": "biome check . && bun run check:types",
|
|
28
|
+
"check:types": "tsgo -p tsconfig.json --noEmit",
|
|
29
|
+
"lint": "biome lint .",
|
|
30
|
+
"fix": "biome check --write --unsafe .",
|
|
31
|
+
"fmt": "biome format --write .",
|
|
32
|
+
"test": "bun test --parallel"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@linxiraos/pi-utils": "1.1.0",
|
|
36
|
+
"@larksuiteoapi/node-sdk": "^1.71.1"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@linxiraos/pi-ai": "1.1.0",
|
|
40
|
+
"@types/bun": "^1.3.14"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"@linxiraos/pi-ai": "1.1.0"
|
|
44
|
+
},
|
|
45
|
+
"engines": {
|
|
46
|
+
"bun": ">=1.3.14"
|
|
47
|
+
},
|
|
48
|
+
"files": [
|
|
49
|
+
"src",
|
|
50
|
+
"README.md",
|
|
51
|
+
"CHANGELOG.md"
|
|
52
|
+
],
|
|
53
|
+
"exports": {
|
|
54
|
+
".": {
|
|
55
|
+
"types": "./src/index.ts",
|
|
56
|
+
"import": "./src/index.ts"
|
|
57
|
+
},
|
|
58
|
+
"./*": {
|
|
59
|
+
"types": "./src/*.ts",
|
|
60
|
+
"import": "./src/*.ts"
|
|
61
|
+
},
|
|
62
|
+
"./*.js": "./src/*.ts"
|
|
63
|
+
}
|
|
64
|
+
}
|
package/src/channel.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal IM channel abstraction for `zeta serve` (web/desktop mode only).
|
|
3
|
+
*
|
|
4
|
+
* A `ChatChannel` owns one platform connection: start/stop lifecycle plus
|
|
5
|
+
* outbound text/image delivery. Inbound messages are pushed through the
|
|
6
|
+
* channel's `onMessage` callback (registered at construction) into the
|
|
7
|
+
* coordinator session via `ChannelHost.deliver`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type ChannelId = "wechat" | "feishu" | "telegram";
|
|
11
|
+
|
|
12
|
+
export interface ChatImage {
|
|
13
|
+
data: Uint8Array;
|
|
14
|
+
mime: "image/png" | "image/jpeg";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ChatChannel {
|
|
18
|
+
readonly id: ChannelId;
|
|
19
|
+
start(): Promise<void>;
|
|
20
|
+
stop(): Promise<void>;
|
|
21
|
+
sendText(to: string, text: string): Promise<void>;
|
|
22
|
+
sendImage(to: string, image: ChatImage, caption?: string): Promise<void>;
|
|
23
|
+
}
|
package/src/feishu.ts
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feishu / Lark channel — official `@larksuiteoapi/node-sdk` with the
|
|
3
|
+
* WebSocket long-connection mode (no public webhook needed).
|
|
4
|
+
*
|
|
5
|
+
* Inbound: `im.message.receive_v1` events; only text messages are forwarded.
|
|
6
|
+
* The peer is the `chat_id` (works for both p2p chats and groups), and
|
|
7
|
+
* replies go back with `receive_id_type: "chat_id"`.
|
|
8
|
+
*
|
|
9
|
+
* Outbound: `im.message.create` for text, `im.image.create` (message type)
|
|
10
|
+
* + image message for pictures. The SDK handles `tenant_access_token`
|
|
11
|
+
* internally.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as Lark from "@larksuiteoapi/node-sdk";
|
|
15
|
+
import { logger } from "@linxiraos/pi-utils";
|
|
16
|
+
import type { ChatChannel, ChatImage } from "./channel";
|
|
17
|
+
|
|
18
|
+
export type FeishuInboundHandler = (peer: string, body: string, messageId?: string) => void;
|
|
19
|
+
|
|
20
|
+
export interface FeishuChannelOptions {
|
|
21
|
+
appId: string;
|
|
22
|
+
appSecret: string;
|
|
23
|
+
domain?: "feishu" | "lark";
|
|
24
|
+
onMessage: FeishuInboundHandler;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface FeishuTextEventData {
|
|
28
|
+
message?: {
|
|
29
|
+
message_id?: string;
|
|
30
|
+
chat_id?: string;
|
|
31
|
+
message_type?: string;
|
|
32
|
+
content?: string;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** `bot_p2p_chat_entered` v2 event payload (first contact in a private chat). */
|
|
37
|
+
interface FeishuP2pEnteredData {
|
|
38
|
+
chat_id?: string;
|
|
39
|
+
event?: { chat_id?: string };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const P2P_ONBOARDING_TEXT =
|
|
43
|
+
"你好!我是 Zeta 助手。发送消息即可开始对话。\n\nHello! I'm the Zeta assistant. Send a message to start chatting.";
|
|
44
|
+
|
|
45
|
+
export class FeishuChannel implements ChatChannel {
|
|
46
|
+
readonly id = "feishu" as const;
|
|
47
|
+
readonly #options: FeishuChannelOptions;
|
|
48
|
+
readonly #onMessage: FeishuInboundHandler;
|
|
49
|
+
#client: Lark.Client | null = null;
|
|
50
|
+
#ws: Lark.WSClient | null = null;
|
|
51
|
+
#started = false;
|
|
52
|
+
#startPromise: Promise<void> | null = null;
|
|
53
|
+
|
|
54
|
+
constructor(options: FeishuChannelOptions) {
|
|
55
|
+
this.#options = options;
|
|
56
|
+
this.#onMessage = options.onMessage;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
#sdkDomain(): Lark.Domain {
|
|
60
|
+
return this.#options.domain === "lark" ? Lark.Domain.Lark : Lark.Domain.Feishu;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async start(): Promise<void> {
|
|
64
|
+
if (this.#started) return;
|
|
65
|
+
this.#started = true;
|
|
66
|
+
this.#startPromise = this.#connect();
|
|
67
|
+
await this.#startPromise;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async #connect(): Promise<void> {
|
|
71
|
+
const { appId, appSecret } = this.#options;
|
|
72
|
+
this.#client = new Lark.Client({
|
|
73
|
+
appId,
|
|
74
|
+
appSecret,
|
|
75
|
+
appType: Lark.AppType.SelfBuild,
|
|
76
|
+
domain: this.#sdkDomain(),
|
|
77
|
+
});
|
|
78
|
+
const dispatcher = new Lark.EventDispatcher({});
|
|
79
|
+
dispatcher.register({
|
|
80
|
+
"im.message.receive_v1": (data: FeishuTextEventData) => this.#onEvent(data),
|
|
81
|
+
bot_p2p_chat_entered: (data: FeishuP2pEnteredData) => this.#onP2pEntered(data),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const ws = new Lark.WSClient({
|
|
85
|
+
appId,
|
|
86
|
+
appSecret,
|
|
87
|
+
domain: this.#sdkDomain(),
|
|
88
|
+
loggerLevel: Lark.LoggerLevel.error,
|
|
89
|
+
wsConfig: { pingTimeout: 3 },
|
|
90
|
+
onError: err => {
|
|
91
|
+
logger.warn("Feishu WebSocket error", {
|
|
92
|
+
error: err instanceof Error ? err.message : String(err),
|
|
93
|
+
});
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
this.#ws = ws;
|
|
97
|
+
await ws.start({ eventDispatcher: dispatcher });
|
|
98
|
+
logger.info("Feishu channel connected (WebSocket long connection)");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async stop(): Promise<void> {
|
|
102
|
+
if (!this.#started) return;
|
|
103
|
+
this.#started = false;
|
|
104
|
+
try {
|
|
105
|
+
this.#ws?.close({ force: true });
|
|
106
|
+
} catch (error) {
|
|
107
|
+
logger.warn("Feishu WebSocket close failed", {
|
|
108
|
+
error: error instanceof Error ? error.message : String(error),
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
this.#ws = null;
|
|
112
|
+
this.#client = null;
|
|
113
|
+
this.#startPromise = null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
#onEvent(data: FeishuTextEventData): void {
|
|
117
|
+
const message = data.message;
|
|
118
|
+
if (message?.message_type !== "text") return;
|
|
119
|
+
const chatId = message.chat_id;
|
|
120
|
+
let text = "";
|
|
121
|
+
try {
|
|
122
|
+
const parsed = JSON.parse(message.content ?? "{}") as { text?: string };
|
|
123
|
+
text = parsed.text ?? "";
|
|
124
|
+
} catch {
|
|
125
|
+
text = message.content ?? "";
|
|
126
|
+
}
|
|
127
|
+
if (typeof chatId !== "string" || chatId === "") return;
|
|
128
|
+
// Feishu renders `@` as mention placeholders (`@_user_1`); strip them so
|
|
129
|
+
// command text and prompts arrive clean (a bot @-mention in a group is
|
|
130
|
+
// not part of the user's actual message). Only collapse the mention
|
|
131
|
+
// gaps — preserve intentional newlines in multi-line prompts.
|
|
132
|
+
text = text
|
|
133
|
+
.replace(/@_user_\d+/g, " ")
|
|
134
|
+
.replace(/[ \t]{2,}/g, " ")
|
|
135
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
136
|
+
.trim();
|
|
137
|
+
if (text === "") return;
|
|
138
|
+
logger.debug("Feishu message received", { chatId, length: text.length });
|
|
139
|
+
this.#onMessage(chatId, text, message.message_id);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
#onP2pEntered(data: FeishuP2pEnteredData): void {
|
|
143
|
+
const chatId = data.chat_id ?? data.event?.chat_id;
|
|
144
|
+
if (typeof chatId !== "string" || chatId === "") return;
|
|
145
|
+
logger.info("Feishu first p2p contact; sending onboarding", { chatId });
|
|
146
|
+
void this.sendText(chatId, P2P_ONBOARDING_TEXT).catch(error => {
|
|
147
|
+
logger.warn("Feishu onboarding reply failed", {
|
|
148
|
+
error: error instanceof Error ? error.message : String(error),
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
#requireClient(): Lark.Client {
|
|
154
|
+
if (!this.#client) throw new Error("Feishu channel is not started");
|
|
155
|
+
return this.#client;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async sendText(to: string, text: string): Promise<void> {
|
|
159
|
+
const client = this.#requireClient();
|
|
160
|
+
const res = await client.im.message.create({
|
|
161
|
+
params: { receive_id_type: "chat_id" },
|
|
162
|
+
data: {
|
|
163
|
+
receive_id: to,
|
|
164
|
+
msg_type: "text",
|
|
165
|
+
content: JSON.stringify({ text }),
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
if (res.code !== 0) {
|
|
169
|
+
throw new Error(`Feishu sendMessage failed: code=${String(res.code)} msg=${res.msg ?? ""}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async sendImage(to: string, image: ChatImage, caption?: string): Promise<void> {
|
|
174
|
+
const client = this.#requireClient();
|
|
175
|
+
const upload = await client.im.image.create({
|
|
176
|
+
data: {
|
|
177
|
+
image_type: "message",
|
|
178
|
+
image: Buffer.from(image.data),
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
const imageKey = upload?.image_key;
|
|
182
|
+
if (!imageKey) {
|
|
183
|
+
throw new Error("Feishu image upload failed: no image_key returned");
|
|
184
|
+
}
|
|
185
|
+
const res = await client.im.message.create({
|
|
186
|
+
params: { receive_id_type: "chat_id" },
|
|
187
|
+
data: {
|
|
188
|
+
receive_id: to,
|
|
189
|
+
msg_type: "image",
|
|
190
|
+
content: JSON.stringify({ image_key: imageKey }),
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
if (res.code !== 0) {
|
|
194
|
+
throw new Error(`Feishu image message failed: code=${String(res.code)} msg=${res.msg ?? ""}`);
|
|
195
|
+
}
|
|
196
|
+
if (caption && caption !== "") {
|
|
197
|
+
await this.sendText(to, caption);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
package/src/host.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChannelHost — bridges IM channels into the coordinator `AgentSession`.
|
|
3
|
+
*
|
|
4
|
+
* Inbound: each channel message becomes an `IrcMessage` delivered through
|
|
5
|
+
* `AgentSession.deliverIrcMessage` (the same IRC injection path agents use),
|
|
6
|
+
* so interrupts, asides, plan-mode handling, and ephemeral auto-replies all
|
|
7
|
+
* behave identically to IRC traffic.
|
|
8
|
+
*
|
|
9
|
+
* Outbound: the agent's final reply to a channel message is forwarded back
|
|
10
|
+
* to the sender. Two paths exist:
|
|
11
|
+
* - a real woken turn → the turn's final assistant text is captured from the
|
|
12
|
+
* `turn_end` session event;
|
|
13
|
+
* - the IRC ephemeral auto-reply path (busy session with async disabled, or
|
|
14
|
+
* plan-mode idle) → `IrcBridge` reports the reply text through the
|
|
15
|
+
* `onAutoReply` hook.
|
|
16
|
+
*
|
|
17
|
+
* The agent can also push progress proactively with the `channel_send` tool
|
|
18
|
+
* (Phase 3); working-tool output is never forwarded automatically.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { AssistantMessage, TextContent } from "@linxiraos/pi-ai";
|
|
22
|
+
import { logger, Snowflake } from "@linxiraos/pi-utils";
|
|
23
|
+
import type { ChannelId } from "./channel";
|
|
24
|
+
import type { ChannelSession, ChannelSessionEvent, IrcMessage } from "./types";
|
|
25
|
+
|
|
26
|
+
/** Outbound sink: resolves a channel + peer to a `sendText` call. */
|
|
27
|
+
export type ChannelSendFn = (channelId: ChannelId, to: string, text: string) => Promise<void>;
|
|
28
|
+
|
|
29
|
+
interface PendingReply {
|
|
30
|
+
channelId: ChannelId;
|
|
31
|
+
peer: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function assistantText(message: AssistantMessage | undefined): string {
|
|
35
|
+
if (message?.role !== "assistant" || !Array.isArray(message.content)) return "";
|
|
36
|
+
return message.content
|
|
37
|
+
.filter((content): content is TextContent => content.type === "text")
|
|
38
|
+
.map(content => content.text)
|
|
39
|
+
.join("")
|
|
40
|
+
.trim();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class ChannelHost {
|
|
44
|
+
readonly #session: ChannelSession;
|
|
45
|
+
readonly #send: ChannelSendFn;
|
|
46
|
+
readonly #allowedPeers: readonly string[];
|
|
47
|
+
#pending: PendingReply[] = [];
|
|
48
|
+
#unsubscribe: (() => void) | null = null;
|
|
49
|
+
#lastInbound: { channelId: ChannelId; peer: string } | null = null;
|
|
50
|
+
|
|
51
|
+
constructor(session: ChannelSession, send: ChannelSendFn, allowedPeers?: readonly string[]) {
|
|
52
|
+
this.#session = session;
|
|
53
|
+
this.#send = send;
|
|
54
|
+
this.#allowedPeers = allowedPeers ?? [];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
get session(): ChannelSession {
|
|
58
|
+
return this.#session;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Most recent successfully injected inbound (channel, peer) — the default
|
|
62
|
+
* target for `channel_send` calls that omit `to`/`channel`. */
|
|
63
|
+
get lastInbound(): { channelId: ChannelId; peer: string } | null {
|
|
64
|
+
return this.#lastInbound;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Attach the session event listener + IRC auto-reply hook. */
|
|
68
|
+
start(): void {
|
|
69
|
+
if (this.#unsubscribe) return;
|
|
70
|
+
this.#unsubscribe = this.#session.subscribe(event => this.#onSessionEvent(event));
|
|
71
|
+
this.#session.setIrcAutoReplyListener((msg, replyText) => this.#onAutoReply(msg, replyText));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Detach listeners and drop pending reply bindings. */
|
|
75
|
+
stop(): void {
|
|
76
|
+
this.#unsubscribe?.();
|
|
77
|
+
this.#unsubscribe = null;
|
|
78
|
+
this.#session.setIrcAutoReplyListener(null);
|
|
79
|
+
this.#pending = [];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Inject one inbound channel message into the coordinator session and
|
|
84
|
+
* bind the sender so the turn's final reply returns to them.
|
|
85
|
+
*/
|
|
86
|
+
async deliver(channelId: ChannelId, peer: string, body: string): Promise<void> {
|
|
87
|
+
// Optional allowlist: when configured, only listed peers may reach the
|
|
88
|
+
// agent (empty allowlist = everyone, unchanged behavior).
|
|
89
|
+
if (this.#allowedPeers.length > 0 && !this.#allowedPeers.includes(peer)) {
|
|
90
|
+
logger.debug("Channel message from non-allowed peer dropped", { channel: channelId, peer });
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const agentId = this.#session.getAgentId();
|
|
94
|
+
if (!agentId) throw new Error("Session has no agent id");
|
|
95
|
+
const msg: IrcMessage = {
|
|
96
|
+
id: Snowflake.next(),
|
|
97
|
+
from: peer,
|
|
98
|
+
to: agentId,
|
|
99
|
+
body,
|
|
100
|
+
ts: Date.now(),
|
|
101
|
+
};
|
|
102
|
+
this.#pending.push({ channelId, peer });
|
|
103
|
+
try {
|
|
104
|
+
await this.#session.deliverIrcMessage(msg, { expectsReply: true });
|
|
105
|
+
this.#lastInbound = { channelId, peer };
|
|
106
|
+
} catch (error) {
|
|
107
|
+
this.#pending = this.#pending.filter(p => !(p.channelId === channelId && p.peer === peer));
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
#onSessionEvent(event: ChannelSessionEvent): void {
|
|
113
|
+
if (event.type !== "turn_end") return;
|
|
114
|
+
const pending = this.#pending.shift();
|
|
115
|
+
if (!pending) return;
|
|
116
|
+
const text = assistantText(event.message as AssistantMessage);
|
|
117
|
+
if (!text) return;
|
|
118
|
+
void this.#send(pending.channelId, pending.peer, text).catch(error => {
|
|
119
|
+
logger.warn("Channel reply delivery failed", {
|
|
120
|
+
channel: pending.channelId,
|
|
121
|
+
error: error instanceof Error ? error.message : String(error),
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
#onAutoReply(_msg: IrcMessage, replyText: string): void {
|
|
127
|
+
const pending = this.#pending.shift();
|
|
128
|
+
if (!pending) return;
|
|
129
|
+
const body = replyText.trim();
|
|
130
|
+
if (!body) return;
|
|
131
|
+
void this.#send(pending.channelId, pending.peer, body).catch(error => {
|
|
132
|
+
logger.warn("Channel auto-reply delivery failed", {
|
|
133
|
+
channel: pending.channelId,
|
|
134
|
+
error: error instanceof Error ? error.message : String(error),
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channel bootstrapping for `zeta serve` (web/desktop mode only).
|
|
3
|
+
*
|
|
4
|
+
* Enables every channel marked `enabled` in the web config and routes its
|
|
5
|
+
* inbound messages into the coordinator session through `ChannelHost`.
|
|
6
|
+
* Channels missing required credentials are skipped with a warning — a
|
|
7
|
+
* misconfigured channel must never prevent the server from starting.
|
|
8
|
+
*
|
|
9
|
+
* The CLI never imports this module: `zeta`/`zeta --interactive` sessions
|
|
10
|
+
* don't create a ZetaServer, so no channel listeners exist there and
|
|
11
|
+
* `channel_send`/`workspace_run` stay unavailable.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { logger } from "@linxiraos/pi-utils";
|
|
15
|
+
import type { ChannelId, ChatChannel, ChatImage } from "./channel";
|
|
16
|
+
import { FeishuChannel } from "./feishu";
|
|
17
|
+
import { ChannelHost } from "./host";
|
|
18
|
+
import { TelegramChannel } from "./telegram";
|
|
19
|
+
import type { ChannelSession, ChannelsWebConfig } from "./types";
|
|
20
|
+
import { WeChatChannel, type WeChatQrStatus } from "./wechat";
|
|
21
|
+
|
|
22
|
+
export type { ChannelId, ChatChannel, ChatImage } from "./channel";
|
|
23
|
+
export { FeishuChannel, type FeishuChannelOptions, type FeishuInboundHandler } from "./feishu";
|
|
24
|
+
export { ChannelHost } from "./host";
|
|
25
|
+
export { TelegramChannel, type TelegramChannelOptions, type TelegramInboundHandler } from "./telegram";
|
|
26
|
+
export type { ChannelSession, ChannelSessionEvent, ChannelsWebConfig, IrcMessage } from "./types";
|
|
27
|
+
export { WeChatChannel, type WeChatChannelOptions, type WeChatInboundHandler, type WeChatQrStatus } from "./wechat";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Module-level QR-login state bridge between the running channel and the web
|
|
31
|
+
* gateway (the gateway dispatches in-process, so a plain module variable is
|
|
32
|
+
* the shared seam — mirroring `running-sessions.ts`).
|
|
33
|
+
*/
|
|
34
|
+
let pendingWechatQr: WeChatQrStatus | null = null;
|
|
35
|
+
let reconnectWechat: (() => Promise<void>) | null = null;
|
|
36
|
+
let unbindWechat: (() => Promise<void>) | null = null;
|
|
37
|
+
let restartChannels: (() => Promise<void>) | null = null;
|
|
38
|
+
let mainSessionId: string | null = null;
|
|
39
|
+
let channelStatus: (() => ChannelStatus[]) | null = null;
|
|
40
|
+
|
|
41
|
+
/** One channel's runtime state, surfaced to the gateway (`/api/channels/status`). */
|
|
42
|
+
export interface ChannelStatus {
|
|
43
|
+
id: ChannelId;
|
|
44
|
+
running: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function registerChannelStatus(fn: (() => ChannelStatus[]) | null): void {
|
|
48
|
+
channelStatus = fn;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function getChannelStatus(): ChannelStatus[] {
|
|
52
|
+
return channelStatus?.() ?? [];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function setPendingWechatQr(payload: WeChatQrStatus | null): void {
|
|
56
|
+
pendingWechatQr = payload;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function getPendingWechatQr(): WeChatQrStatus | null {
|
|
60
|
+
return pendingWechatQr;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Register the serve process's shared coordinator session id (zeta-server
|
|
65
|
+
* wires this). External clients (web-ui default chat, `zeta attach`) resolve
|
|
66
|
+
* the shared session through `GET /api/agent/current`, which reads this.
|
|
67
|
+
*/
|
|
68
|
+
export function registerMainSessionId(id: string | null): void {
|
|
69
|
+
mainSessionId = id;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The persistent id of the serve process's shared coordinator session, if any. */
|
|
73
|
+
export function getMainSessionId(): string | null {
|
|
74
|
+
return mainSessionId;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Register the running WeChat channel's reconnect hook (zeta-server wires this). */
|
|
78
|
+
export function registerWechatReconnect(fn: (() => Promise<void>) | null): void {
|
|
79
|
+
reconnectWechat = fn;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Trigger a fresh QR login on the running WeChat channel, if any. */
|
|
83
|
+
export function triggerWechatReconnect(): Promise<void> | null {
|
|
84
|
+
return reconnectWechat?.() ?? null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Register the running WeChat channel's unbind hook (zeta-server wires this). */
|
|
88
|
+
export function registerWechatUnbind(fn: (() => Promise<void>) | null): void {
|
|
89
|
+
unbindWechat = fn;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Unbind the running WeChat channel (clears credentials + peer bindings). */
|
|
93
|
+
export function triggerWechatUnbind(): Promise<void> | null {
|
|
94
|
+
return unbindWechat?.() ?? null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Register the live channel (re)start hook (zeta-server wires this). */
|
|
98
|
+
export function registerRestartChannels(fn: (() => Promise<void>) | null): void {
|
|
99
|
+
restartChannels = fn;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Re-apply the channel config against the running serve process: stop the
|
|
104
|
+
* current runtime and start whichever channels web.yml now enables. The web
|
|
105
|
+
* gateway calls this after a `channels.*` PUT so toggling a channel in the UI
|
|
106
|
+
* takes effect immediately (QR login included) without a serve restart.
|
|
107
|
+
*/
|
|
108
|
+
export function triggerRestartChannels(): Promise<void> | null {
|
|
109
|
+
return restartChannels?.() ?? null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface ChannelRuntime {
|
|
113
|
+
/** Started channels keyed by id (enabled + configured only). */
|
|
114
|
+
readonly channels: ReadonlyMap<ChannelId, ChatChannel>;
|
|
115
|
+
/** Host binding the coordinator session to the channels. */
|
|
116
|
+
readonly host: ChannelHost;
|
|
117
|
+
/** Send a text message through one channel. */
|
|
118
|
+
sendText(channelId: ChannelId, to: string, text: string): Promise<void>;
|
|
119
|
+
/** Send an image through one channel (falls back to caller on error). */
|
|
120
|
+
sendImage(channelId: ChannelId, to: string, image: ChatImage, caption?: string): Promise<void>;
|
|
121
|
+
/** Stop every channel and detach the host. */
|
|
122
|
+
stop(): Promise<void>;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Handle one inbound channel message (the Phase-3 routing seam lives here). */
|
|
126
|
+
export type ChannelInboundHandler = (channelId: ChannelId, peer: string, body: string, messageId?: string) => void;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Start all enabled channels for the given coordinator session.
|
|
130
|
+
* `onInbound` receives every inbound message after channel-level filtering;
|
|
131
|
+
* default wiring injects into the session via the host.
|
|
132
|
+
*/
|
|
133
|
+
export async function startChannels(
|
|
134
|
+
session: ChannelSession,
|
|
135
|
+
webConfig: ChannelsWebConfig,
|
|
136
|
+
onInbound?: ChannelInboundHandler,
|
|
137
|
+
wechatQrHandler?: (payload: WeChatQrStatus) => void,
|
|
138
|
+
): Promise<ChannelRuntime> {
|
|
139
|
+
const data = webConfig.getData();
|
|
140
|
+
const host = new ChannelHost(
|
|
141
|
+
session,
|
|
142
|
+
(channelId, to, text) => sendText(channelId, to, text),
|
|
143
|
+
data.channels.allowedPeers,
|
|
144
|
+
);
|
|
145
|
+
const channels = new Map<ChannelId, ChatChannel>();
|
|
146
|
+
|
|
147
|
+
const routeInbound: ChannelInboundHandler =
|
|
148
|
+
onInbound ??
|
|
149
|
+
((channelId, peer, body) => {
|
|
150
|
+
void host.deliver(channelId, peer, body).catch(error => {
|
|
151
|
+
logger.warn("Channel message injection failed", {
|
|
152
|
+
channel: channelId,
|
|
153
|
+
error: error instanceof Error ? error.message : String(error),
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Telegram
|
|
159
|
+
if (data.channels.telegram.enabled) {
|
|
160
|
+
const botToken = data.channels.telegram.botToken;
|
|
161
|
+
if (botToken) {
|
|
162
|
+
channels.set(
|
|
163
|
+
"telegram",
|
|
164
|
+
new TelegramChannel({
|
|
165
|
+
botToken,
|
|
166
|
+
onMessage: (peer, body, messageId) => routeInbound("telegram", peer, body, messageId),
|
|
167
|
+
}),
|
|
168
|
+
);
|
|
169
|
+
} else {
|
|
170
|
+
logger.warn("Telegram channel is enabled but missing botToken; skipping");
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// WeChat (ClawBot / iLink)
|
|
175
|
+
if (data.channels.wechat.enabled) {
|
|
176
|
+
channels.set(
|
|
177
|
+
"wechat",
|
|
178
|
+
new WeChatChannel({
|
|
179
|
+
config: {
|
|
180
|
+
botToken: data.channels.wechat.botToken,
|
|
181
|
+
ilinkBotId: data.channels.wechat.ilinkBotId,
|
|
182
|
+
ilinkUserId: data.channels.wechat.ilinkUserId,
|
|
183
|
+
baseUrl: data.channels.wechat.baseUrl,
|
|
184
|
+
endpoint: data.channels.wechat.endpoint,
|
|
185
|
+
peerTokens: data.channels.wechat.peerTokens,
|
|
186
|
+
},
|
|
187
|
+
webConfig,
|
|
188
|
+
onMessage: (peer, body, messageId) => routeInbound("wechat", peer, body, messageId),
|
|
189
|
+
onQrCode: wechatQrHandler,
|
|
190
|
+
}),
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Feishu / Lark
|
|
195
|
+
if (data.channels.feishu.enabled) {
|
|
196
|
+
const appId = data.channels.feishu.appId;
|
|
197
|
+
const appSecret = data.channels.feishu.appSecret;
|
|
198
|
+
if (appId && appSecret) {
|
|
199
|
+
channels.set(
|
|
200
|
+
"feishu",
|
|
201
|
+
new FeishuChannel({
|
|
202
|
+
appId,
|
|
203
|
+
appSecret,
|
|
204
|
+
domain: data.channels.feishu.domain,
|
|
205
|
+
onMessage: (peer, body, messageId) => routeInbound("feishu", peer, body, messageId),
|
|
206
|
+
}),
|
|
207
|
+
);
|
|
208
|
+
} else {
|
|
209
|
+
logger.warn("Feishu channel is enabled but missing appId/appSecret; skipping");
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
host.start();
|
|
214
|
+
|
|
215
|
+
const sendText = async (channelId: ChannelId, to: string, text: string): Promise<void> => {
|
|
216
|
+
const channel = channels.get(channelId);
|
|
217
|
+
if (!channel) throw new Error(`Channel not enabled: ${channelId}`);
|
|
218
|
+
await channel.sendText(to, text);
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
for (const channel of channels.values()) {
|
|
222
|
+
try {
|
|
223
|
+
await channel.start();
|
|
224
|
+
} catch (error) {
|
|
225
|
+
logger.warn(`Channel ${channel.id} failed to start`, {
|
|
226
|
+
error: error instanceof Error ? error.message : String(error),
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
channels,
|
|
233
|
+
host,
|
|
234
|
+
sendText,
|
|
235
|
+
sendImage: async (channelId, to, image, caption) => {
|
|
236
|
+
const channel = channels.get(channelId);
|
|
237
|
+
if (!channel) throw new Error(`Channel not enabled: ${channelId}`);
|
|
238
|
+
await channel.sendImage(to, image, caption);
|
|
239
|
+
},
|
|
240
|
+
stop: async () => {
|
|
241
|
+
host.stop();
|
|
242
|
+
await Promise.allSettled([...channels.values()].map(channel => channel.stop()));
|
|
243
|
+
channels.clear();
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
}
|