@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/src/telegram.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram channel — Bot API long polling, no dependencies.
|
|
3
|
+
*
|
|
4
|
+
* Inbound: `getUpdates` with a 35s poll timeout; the `offset` cursor is
|
|
5
|
+
* advanced past every received `update_id` so the API never redelivers.
|
|
6
|
+
* Only `message.text` payloads are forwarded (media and `/command` messages
|
|
7
|
+
* are ignored).
|
|
8
|
+
*
|
|
9
|
+
* Outbound: `sendMessage` for text, multipart `sendPhoto` for images.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { logger } from "@linxiraos/pi-utils";
|
|
13
|
+
import type { ChatChannel, ChatImage } from "./channel";
|
|
14
|
+
|
|
15
|
+
export type TelegramInboundHandler = (peer: string, body: string, messageId?: string) => void;
|
|
16
|
+
|
|
17
|
+
export interface TelegramChannelOptions {
|
|
18
|
+
botToken: string;
|
|
19
|
+
onMessage: TelegramInboundHandler;
|
|
20
|
+
/** Test seam: inject a custom fetch implementation (defaults to global fetch). */
|
|
21
|
+
customFetch?: typeof globalThis.fetch;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const API_BASE = "https://api.telegram.org";
|
|
25
|
+
const POLL_TIMEOUT_SECONDS = 35;
|
|
26
|
+
const REQUEST_TIMEOUT_MS = 70_000;
|
|
27
|
+
const RETRY_DELAY_MS = 3_000;
|
|
28
|
+
|
|
29
|
+
export class TelegramChannel implements ChatChannel {
|
|
30
|
+
readonly id = "telegram" as const;
|
|
31
|
+
readonly #botToken: string;
|
|
32
|
+
readonly #onMessage: TelegramInboundHandler;
|
|
33
|
+
readonly #fetch: typeof globalThis.fetch;
|
|
34
|
+
#offset = 0;
|
|
35
|
+
#started = false;
|
|
36
|
+
#abort: AbortController | null = null;
|
|
37
|
+
#loop: Promise<void> | null = null;
|
|
38
|
+
|
|
39
|
+
constructor(options: TelegramChannelOptions) {
|
|
40
|
+
this.#botToken = options.botToken;
|
|
41
|
+
this.#onMessage = options.onMessage;
|
|
42
|
+
this.#fetch = options.customFetch ?? globalThis.fetch;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async start(): Promise<void> {
|
|
46
|
+
if (this.#started) return;
|
|
47
|
+
this.#started = true;
|
|
48
|
+
this.#abort = new AbortController();
|
|
49
|
+
this.#loop = this.#pollLoop();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async stop(): Promise<void> {
|
|
53
|
+
if (!this.#started) return;
|
|
54
|
+
this.#started = false;
|
|
55
|
+
this.#abort?.abort();
|
|
56
|
+
await this.#loop?.catch(() => {});
|
|
57
|
+
this.#loop = null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
#apiUrl(method: string): string {
|
|
61
|
+
return `${API_BASE}/bot${this.#botToken}/${method}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async #pollLoop(): Promise<void> {
|
|
65
|
+
while (this.#started && !this.#abort?.signal.aborted) {
|
|
66
|
+
try {
|
|
67
|
+
const url = `${this.#apiUrl("getUpdates")}?timeout=${POLL_TIMEOUT_SECONDS}&offset=${this.#offset + 1}`;
|
|
68
|
+
const res = await this.#fetch(url, {
|
|
69
|
+
signal: AbortSignal.any([this.#abort!.signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]),
|
|
70
|
+
});
|
|
71
|
+
if (res.status === 401) {
|
|
72
|
+
logger.error("Telegram bot token rejected (HTTP 401); polling stopped");
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
if (!res.ok) {
|
|
76
|
+
logger.warn("Telegram getUpdates failed", { status: res.status });
|
|
77
|
+
await Bun.sleep(RETRY_DELAY_MS);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const data = (await res.json()) as {
|
|
81
|
+
ok?: boolean;
|
|
82
|
+
result?: Array<{
|
|
83
|
+
update_id?: number;
|
|
84
|
+
message?: { message_id?: number; chat?: { id?: number }; text?: string };
|
|
85
|
+
}>;
|
|
86
|
+
};
|
|
87
|
+
for (const update of data.result ?? []) {
|
|
88
|
+
if (typeof update.update_id === "number") {
|
|
89
|
+
this.#offset = Math.max(this.#offset, update.update_id);
|
|
90
|
+
}
|
|
91
|
+
const chatId = update.message?.chat?.id;
|
|
92
|
+
const text = update.message?.text;
|
|
93
|
+
if (chatId === undefined || typeof text !== "string" || text === "") continue;
|
|
94
|
+
if (text.startsWith("/")) continue;
|
|
95
|
+
logger.debug("Telegram message received", { chatId, length: text.length });
|
|
96
|
+
this.#onMessage(String(chatId), text, String(update.message?.message_id ?? ""));
|
|
97
|
+
}
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (this.#abort?.signal.aborted) break;
|
|
100
|
+
if (error instanceof Error && error.name === "TimeoutError") continue;
|
|
101
|
+
logger.warn("Telegram polling error", {
|
|
102
|
+
error: error instanceof Error ? error.message : String(error),
|
|
103
|
+
});
|
|
104
|
+
await Bun.sleep(RETRY_DELAY_MS);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async sendText(to: string, text: string): Promise<void> {
|
|
110
|
+
const res = await this.#fetch(this.#apiUrl("sendMessage"), {
|
|
111
|
+
method: "POST",
|
|
112
|
+
headers: { "Content-Type": "application/json" },
|
|
113
|
+
body: JSON.stringify({ chat_id: to, text }),
|
|
114
|
+
});
|
|
115
|
+
if (!res.ok) {
|
|
116
|
+
throw new Error(`Telegram sendMessage failed (HTTP ${res.status}): ${await res.text()}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async sendImage(to: string, image: ChatImage, caption?: string): Promise<void> {
|
|
121
|
+
const form = new FormData();
|
|
122
|
+
form.append("chat_id", to);
|
|
123
|
+
form.append("photo", new Blob([image.data], { type: image.mime }), "plan.png");
|
|
124
|
+
if (caption && caption !== "") form.append("caption", caption);
|
|
125
|
+
const res = await this.#fetch(this.#apiUrl("sendPhoto"), { method: "POST", body: form });
|
|
126
|
+
if (!res.ok) {
|
|
127
|
+
throw new Error(`Telegram sendPhoto failed (HTTP ${res.status}): ${await res.text()}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural contracts for the IM channel adapters.
|
|
3
|
+
*
|
|
4
|
+
* pi-channels intentionally depends on zeta only through these shapes: the
|
|
5
|
+
* real `AgentSession` and `WebConfig` satisfy them without importing this
|
|
6
|
+
* package's types. Fields below mirror exactly what host.ts / index.ts /
|
|
7
|
+
* wechat.ts read off the zeta objects.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Message shape carried over from zeta's src/irc/bus.ts IrcMessage. */
|
|
11
|
+
export interface IrcMessage {
|
|
12
|
+
id: string;
|
|
13
|
+
/** Sender agent id. */
|
|
14
|
+
from: string;
|
|
15
|
+
/** Recipient agent id. */
|
|
16
|
+
to: string;
|
|
17
|
+
body: string;
|
|
18
|
+
ts: number;
|
|
19
|
+
/** Message id being answered. */
|
|
20
|
+
replyTo?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* host.ts/index.ts structured consumption surface of the coordinator session
|
|
25
|
+
* (zeta's `AgentSession` satisfies this structurally). `getAgentId` matches
|
|
26
|
+
* the real return type (`string | undefined`), and `subscribe`'s listener is
|
|
27
|
+
* a supertype of zeta's `AgentSessionEvent` so method bivariance lets the
|
|
28
|
+
* real session satisfy it.
|
|
29
|
+
*/
|
|
30
|
+
export interface ChannelSession {
|
|
31
|
+
getAgentId(): string | undefined;
|
|
32
|
+
deliverIrcMessage(msg: IrcMessage, opts?: { expectsReply?: boolean }): Promise<unknown>;
|
|
33
|
+
subscribe(handler: (event: ChannelSessionEvent) => void): () => void;
|
|
34
|
+
setIrcAutoReplyListener(listener: ((msg: IrcMessage, replyText: string) => void) | null): void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* host.ts #onSessionEvent only reads `event.type` and then casts
|
|
39
|
+
* `event.message` to `AssistantMessage`; every zeta `AgentSessionEvent`
|
|
40
|
+
* variant is assignable to this loose shape.
|
|
41
|
+
*/
|
|
42
|
+
export interface ChannelSessionEvent {
|
|
43
|
+
type: string;
|
|
44
|
+
message?: unknown;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* index.ts/wechat.ts consumption surface of the web config. Fields match the
|
|
49
|
+
* `data.channels.*` reads in `startChannels` plus the `set` calls WeChat uses
|
|
50
|
+
* to persist credentials.
|
|
51
|
+
*/
|
|
52
|
+
export interface ChannelsWebConfig {
|
|
53
|
+
getData(): {
|
|
54
|
+
channels: {
|
|
55
|
+
wechat: {
|
|
56
|
+
enabled: boolean;
|
|
57
|
+
botToken?: string;
|
|
58
|
+
ilinkBotId?: string;
|
|
59
|
+
ilinkUserId?: string;
|
|
60
|
+
baseUrl?: string;
|
|
61
|
+
endpoint?: string;
|
|
62
|
+
peerTokens?: Record<string, string>;
|
|
63
|
+
};
|
|
64
|
+
feishu: { enabled: boolean; appId?: string; appSecret?: string; domain?: "feishu" | "lark" };
|
|
65
|
+
telegram: { enabled: boolean; botToken?: string };
|
|
66
|
+
allowedPeers?: string[];
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
set(path: string, value: unknown): Promise<void>;
|
|
70
|
+
}
|