@botcord/daemon 0.2.36 → 0.2.38
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/config.d.ts +29 -0
- package/dist/config.js +27 -0
- package/dist/daemon-config-map.d.ts +3 -0
- package/dist/daemon-config-map.js +30 -0
- package/dist/daemon.d.ts +15 -1
- package/dist/daemon.js +56 -11
- package/dist/gateway/channels/botcord.js +44 -0
- package/dist/gateway/channels/http-types.d.ts +19 -0
- package/dist/gateway/channels/http-types.js +1 -0
- package/dist/gateway/channels/index.d.ts +5 -0
- package/dist/gateway/channels/index.js +5 -0
- package/dist/gateway/channels/login-session.d.ts +83 -0
- package/dist/gateway/channels/login-session.js +99 -0
- package/dist/gateway/channels/secret-store.d.ts +21 -0
- package/dist/gateway/channels/secret-store.js +75 -0
- package/dist/gateway/channels/state-store.d.ts +60 -0
- package/dist/gateway/channels/state-store.js +173 -0
- package/dist/gateway/channels/telegram.d.ts +31 -0
- package/dist/gateway/channels/telegram.js +371 -0
- package/dist/gateway/channels/text-split.d.ts +13 -0
- package/dist/gateway/channels/text-split.js +33 -0
- package/dist/gateway/channels/url-guard.d.ts +18 -0
- package/dist/gateway/channels/url-guard.js +53 -0
- package/dist/gateway/channels/wechat-http.d.ts +18 -0
- package/dist/gateway/channels/wechat-http.js +28 -0
- package/dist/gateway/channels/wechat-login.d.ts +36 -0
- package/dist/gateway/channels/wechat-login.js +62 -0
- package/dist/gateway/channels/wechat.d.ts +40 -0
- package/dist/gateway/channels/wechat.js +472 -0
- package/dist/gateway/runtimes/openclaw-acp.js +211 -6
- package/dist/gateway/types.d.ts +10 -0
- package/dist/gateway-control.d.ts +53 -0
- package/dist/gateway-control.js +638 -0
- package/dist/provision.d.ts +7 -0
- package/dist/provision.js +255 -5
- package/package.json +1 -1
- package/src/__tests__/gateway-control.test.ts +499 -0
- package/src/__tests__/openclaw-acp.test.ts +63 -0
- package/src/__tests__/provision.test.ts +179 -0
- package/src/__tests__/secret-store.test.ts +70 -0
- package/src/__tests__/state-store.test.ts +119 -0
- package/src/__tests__/third-party-gateway.test.ts +126 -0
- package/src/__tests__/url-guard.test.ts +85 -0
- package/src/__tests__/wechat-channel.test.ts +1134 -0
- package/src/config.ts +71 -0
- package/src/daemon-config-map.ts +24 -0
- package/src/daemon.ts +70 -11
- package/src/gateway/__tests__/botcord-channel.test.ts +1 -1
- package/src/gateway/__tests__/telegram-channel.test.ts +555 -0
- package/src/gateway/channels/botcord.ts +39 -0
- package/src/gateway/channels/http-types.ts +22 -0
- package/src/gateway/channels/index.ts +22 -0
- package/src/gateway/channels/login-session.ts +135 -0
- package/src/gateway/channels/secret-store.ts +100 -0
- package/src/gateway/channels/state-store.ts +213 -0
- package/src/gateway/channels/telegram.ts +469 -0
- package/src/gateway/channels/text-split.ts +29 -0
- package/src/gateway/channels/url-guard.ts +55 -0
- package/src/gateway/channels/wechat-http.ts +35 -0
- package/src/gateway/channels/wechat-login.ts +90 -0
- package/src/gateway/channels/wechat.ts +572 -0
- package/src/gateway/runtimes/openclaw-acp.ts +211 -7
- package/src/gateway/types.ts +10 -0
- package/src/gateway-control.ts +709 -0
- package/src/provision.ts +336 -5
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-disk cursor + provider-state for one third-party gateway. Kept separate
|
|
3
|
+
* from `secret-store` so high-frequency cursor advancements don't churn the
|
|
4
|
+
* secret file (and so cursor doesn't end up in any secret backup).
|
|
5
|
+
*/
|
|
6
|
+
export interface ThirdPartyGatewayState {
|
|
7
|
+
cursor?: string;
|
|
8
|
+
providerState?: Record<string, unknown>;
|
|
9
|
+
updatedAt: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function defaultGatewayStatePath(gatewayId: string, override?: string): string;
|
|
12
|
+
export interface GatewayStateStoreOptions {
|
|
13
|
+
/** Override the on-disk path; defaults to `~/.botcord/daemon/gateways/{id}.state.json`. */
|
|
14
|
+
override?: string;
|
|
15
|
+
/** Debounce window for batching writes; defaults to 1000ms. Use `0` for sync writes (tests). */
|
|
16
|
+
debounceMs?: number;
|
|
17
|
+
}
|
|
18
|
+
export declare class GatewayStateStore {
|
|
19
|
+
private readonly file;
|
|
20
|
+
private readonly debounceMs;
|
|
21
|
+
private state;
|
|
22
|
+
private timer;
|
|
23
|
+
private dirty;
|
|
24
|
+
private flushRetryCount;
|
|
25
|
+
/** W9: most recent write error surfaced for diagnostics. Cleared on success. */
|
|
26
|
+
lastError: Error | null;
|
|
27
|
+
constructor(gatewayId: string, opts?: GatewayStateStoreOptions);
|
|
28
|
+
/** Read the current cursor (in-memory; reflects pending writes). */
|
|
29
|
+
getCursor(): string | undefined;
|
|
30
|
+
/** Read the current provider-state bag. Mutating the returned object does not persist. */
|
|
31
|
+
getProviderState(): Record<string, unknown> | undefined;
|
|
32
|
+
getSnapshot(): ThirdPartyGatewayState;
|
|
33
|
+
/** Update cursor + optional provider state and schedule a debounced flush. */
|
|
34
|
+
update(patch: {
|
|
35
|
+
cursor?: string;
|
|
36
|
+
providerState?: Record<string, unknown>;
|
|
37
|
+
}): void;
|
|
38
|
+
/**
|
|
39
|
+
* Force a synchronous write of the pending state, if any.
|
|
40
|
+
*
|
|
41
|
+
* W9: on write failure, leave `dirty=true` and re-arm the debounce timer
|
|
42
|
+
* so a subsequent `update()` (or background timer fire) retries instead of
|
|
43
|
+
* silently dropping the pending state. The error is also re-thrown so
|
|
44
|
+
* callers that explicitly invoke `flush()` (shutdown paths, tests) see it.
|
|
45
|
+
*/
|
|
46
|
+
flush(): void;
|
|
47
|
+
/** Flush and stop accepting future debounced writes. */
|
|
48
|
+
close(): void;
|
|
49
|
+
/** On-disk path; exposed for tests and diagnostics. */
|
|
50
|
+
get filePath(): string;
|
|
51
|
+
private scheduleFlush;
|
|
52
|
+
/**
|
|
53
|
+
* Re-arm the debounce timer after a write failure. Bounded delay (capped
|
|
54
|
+
* at 5s) so transient failures do not become a hot-spin retry loop.
|
|
55
|
+
* After MAX_FLUSH_RETRIES consecutive failures, log and stop retrying so
|
|
56
|
+
* a permanently broken write path cannot loop indefinitely. `lastError` is
|
|
57
|
+
* left set so callers can detect persistent failure.
|
|
58
|
+
*/
|
|
59
|
+
private scheduleFlushRetry;
|
|
60
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
const DEFAULT_GATEWAYS_DIR = path.join(homedir(), ".botcord", "daemon", "gateways");
|
|
5
|
+
const DEFAULT_DEBOUNCE_MS = 1000;
|
|
6
|
+
export function defaultGatewayStatePath(gatewayId, override) {
|
|
7
|
+
if (override && override.length > 0)
|
|
8
|
+
return override;
|
|
9
|
+
return path.join(DEFAULT_GATEWAYS_DIR, `${gatewayId}.state.json`);
|
|
10
|
+
}
|
|
11
|
+
function readState(file) {
|
|
12
|
+
if (!existsSync(file))
|
|
13
|
+
return { updatedAt: new Date(0).toISOString() };
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return { updatedAt: new Date(0).toISOString() };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function writeStateSync(file, state) {
|
|
22
|
+
const dir = path.dirname(file);
|
|
23
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
24
|
+
const tmp = `${file}.tmp`;
|
|
25
|
+
writeFileSync(tmp, JSON.stringify(state, null, 2), { mode: 0o600 });
|
|
26
|
+
renameSync(tmp, file);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Per-gateway state store with debounced writes. Reads are always synchronous
|
|
30
|
+
* against the in-memory snapshot, so the polling loop sees its own writes
|
|
31
|
+
* even before they are flushed to disk. `flush()` is exposed for shutdown
|
|
32
|
+
* paths and tests; `close()` flushes and clears the timer.
|
|
33
|
+
*/
|
|
34
|
+
const MAX_FLUSH_RETRIES = 10;
|
|
35
|
+
export class GatewayStateStore {
|
|
36
|
+
file;
|
|
37
|
+
debounceMs;
|
|
38
|
+
state;
|
|
39
|
+
timer = null;
|
|
40
|
+
dirty = false;
|
|
41
|
+
flushRetryCount = 0;
|
|
42
|
+
/** W9: most recent write error surfaced for diagnostics. Cleared on success. */
|
|
43
|
+
lastError = null;
|
|
44
|
+
constructor(gatewayId, opts = {}) {
|
|
45
|
+
this.file = defaultGatewayStatePath(gatewayId, opts.override);
|
|
46
|
+
this.debounceMs = opts.debounceMs ?? DEFAULT_DEBOUNCE_MS;
|
|
47
|
+
this.state = readState(this.file);
|
|
48
|
+
}
|
|
49
|
+
/** Read the current cursor (in-memory; reflects pending writes). */
|
|
50
|
+
getCursor() {
|
|
51
|
+
return this.state.cursor;
|
|
52
|
+
}
|
|
53
|
+
/** Read the current provider-state bag. Mutating the returned object does not persist. */
|
|
54
|
+
getProviderState() {
|
|
55
|
+
return this.state.providerState;
|
|
56
|
+
}
|
|
57
|
+
getSnapshot() {
|
|
58
|
+
return { ...this.state };
|
|
59
|
+
}
|
|
60
|
+
/** Update cursor + optional provider state and schedule a debounced flush. */
|
|
61
|
+
update(patch) {
|
|
62
|
+
if (patch.cursor !== undefined)
|
|
63
|
+
this.state.cursor = patch.cursor;
|
|
64
|
+
if (patch.providerState !== undefined) {
|
|
65
|
+
this.state.providerState = patch.providerState;
|
|
66
|
+
}
|
|
67
|
+
this.state.updatedAt = new Date().toISOString();
|
|
68
|
+
this.scheduleFlush();
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Force a synchronous write of the pending state, if any.
|
|
72
|
+
*
|
|
73
|
+
* W9: on write failure, leave `dirty=true` and re-arm the debounce timer
|
|
74
|
+
* so a subsequent `update()` (or background timer fire) retries instead of
|
|
75
|
+
* silently dropping the pending state. The error is also re-thrown so
|
|
76
|
+
* callers that explicitly invoke `flush()` (shutdown paths, tests) see it.
|
|
77
|
+
*/
|
|
78
|
+
flush() {
|
|
79
|
+
if (this.timer) {
|
|
80
|
+
clearTimeout(this.timer);
|
|
81
|
+
this.timer = null;
|
|
82
|
+
}
|
|
83
|
+
if (!this.dirty)
|
|
84
|
+
return;
|
|
85
|
+
try {
|
|
86
|
+
writeStateSync(this.file, this.state);
|
|
87
|
+
this.dirty = false;
|
|
88
|
+
this.lastError = null;
|
|
89
|
+
this.flushRetryCount = 0;
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
this.lastError = err instanceof Error ? err : new Error(String(err));
|
|
93
|
+
// Keep dirty=true so the next update() re-arms a flush. We also
|
|
94
|
+
// schedule a retry now in case the caller has nothing else queued.
|
|
95
|
+
this.scheduleFlushRetry();
|
|
96
|
+
throw this.lastError;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** Flush and stop accepting future debounced writes. */
|
|
100
|
+
close() {
|
|
101
|
+
this.flush();
|
|
102
|
+
}
|
|
103
|
+
/** On-disk path; exposed for tests and diagnostics. */
|
|
104
|
+
get filePath() {
|
|
105
|
+
return this.file;
|
|
106
|
+
}
|
|
107
|
+
scheduleFlush() {
|
|
108
|
+
this.dirty = true;
|
|
109
|
+
if (this.debounceMs <= 0) {
|
|
110
|
+
// W9: synchronous mode — re-throw on failure so the caller sees the
|
|
111
|
+
// problem instead of having the data silently disappear.
|
|
112
|
+
this.flush();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (this.timer)
|
|
116
|
+
return;
|
|
117
|
+
this.timer = setTimeout(() => {
|
|
118
|
+
this.timer = null;
|
|
119
|
+
if (!this.dirty)
|
|
120
|
+
return;
|
|
121
|
+
try {
|
|
122
|
+
writeStateSync(this.file, this.state);
|
|
123
|
+
this.dirty = false;
|
|
124
|
+
this.lastError = null;
|
|
125
|
+
this.flushRetryCount = 0;
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
// W9: keep dirty=true and re-arm so the failed write retries.
|
|
129
|
+
this.lastError = err instanceof Error ? err : new Error(String(err));
|
|
130
|
+
this.scheduleFlushRetry();
|
|
131
|
+
}
|
|
132
|
+
}, this.debounceMs);
|
|
133
|
+
if (typeof this.timer.unref === "function")
|
|
134
|
+
this.timer.unref();
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Re-arm the debounce timer after a write failure. Bounded delay (capped
|
|
138
|
+
* at 5s) so transient failures do not become a hot-spin retry loop.
|
|
139
|
+
* After MAX_FLUSH_RETRIES consecutive failures, log and stop retrying so
|
|
140
|
+
* a permanently broken write path cannot loop indefinitely. `lastError` is
|
|
141
|
+
* left set so callers can detect persistent failure.
|
|
142
|
+
*/
|
|
143
|
+
scheduleFlushRetry() {
|
|
144
|
+
if (this.debounceMs <= 0)
|
|
145
|
+
return; // sync mode: caller decides
|
|
146
|
+
if (this.timer)
|
|
147
|
+
return;
|
|
148
|
+
this.flushRetryCount += 1;
|
|
149
|
+
if (this.flushRetryCount > MAX_FLUSH_RETRIES) {
|
|
150
|
+
// Persistent failure — give up. lastError remains set for diagnostics.
|
|
151
|
+
console.error(`[state-store] flush failed ${MAX_FLUSH_RETRIES} times; giving up on ${this.file}`, this.lastError);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const retryMs = Math.min(Math.max(this.debounceMs, 250), 5000);
|
|
155
|
+
this.timer = setTimeout(() => {
|
|
156
|
+
this.timer = null;
|
|
157
|
+
if (!this.dirty)
|
|
158
|
+
return;
|
|
159
|
+
try {
|
|
160
|
+
writeStateSync(this.file, this.state);
|
|
161
|
+
this.dirty = false;
|
|
162
|
+
this.lastError = null;
|
|
163
|
+
this.flushRetryCount = 0;
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
this.lastError = err instanceof Error ? err : new Error(String(err));
|
|
167
|
+
this.scheduleFlushRetry();
|
|
168
|
+
}
|
|
169
|
+
}, retryMs);
|
|
170
|
+
if (typeof this.timer.unref === "function")
|
|
171
|
+
this.timer.unref();
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ChannelAdapter } from "../types.js";
|
|
2
|
+
/** Options accepted by {@link createTelegramChannel}. */
|
|
3
|
+
export interface TelegramChannelOptions {
|
|
4
|
+
id: string;
|
|
5
|
+
accountId: string;
|
|
6
|
+
/** Bot token. When omitted, the adapter loads it from the secret-store on start. */
|
|
7
|
+
botToken?: string;
|
|
8
|
+
baseUrl?: string;
|
|
9
|
+
/** Empty / missing list = default-deny. */
|
|
10
|
+
allowedSenderIds?: string[];
|
|
11
|
+
/** Empty / missing list = default-deny. */
|
|
12
|
+
allowedChatIds?: string[];
|
|
13
|
+
splitAt?: number;
|
|
14
|
+
secretFile?: string;
|
|
15
|
+
stateFile?: string;
|
|
16
|
+
/** Test hook: override `globalThis.fetch`. */
|
|
17
|
+
fetchImpl?: typeof fetch;
|
|
18
|
+
/** Test hook: synchronous state writes (`debounceMs: 0`). */
|
|
19
|
+
stateDebounceMs?: number;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Telegram channel adapter — long-polls `getUpdates`, normalizes text messages
|
|
23
|
+
* to `GatewayInboundEnvelope`, and writes replies via `sendMessage`. Cursor
|
|
24
|
+
* (`update_id + 1`) is persisted to the state-store so a daemon restart never
|
|
25
|
+
* replays the last batch.
|
|
26
|
+
*
|
|
27
|
+
* Allowlists are default-deny: an empty (or missing) `allowedChatIds` /
|
|
28
|
+
* `allowedSenderIds` rejects every inbound message. This matches the security
|
|
29
|
+
* default in the third-party-gateway design doc.
|
|
30
|
+
*/
|
|
31
|
+
export declare function createTelegramChannel(opts: TelegramChannelOptions): ChannelAdapter;
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import { sanitizeUntrustedContent } from "./sanitize.js";
|
|
2
|
+
import { GatewayStateStore } from "./state-store.js";
|
|
3
|
+
import { loadGatewaySecret } from "./secret-store.js";
|
|
4
|
+
import { splitText } from "./text-split.js";
|
|
5
|
+
const DEFAULT_BASE_URL = "https://api.telegram.org";
|
|
6
|
+
const DEFAULT_SPLIT_AT = 4000; // Telegram hard limit is 4096; leave slack.
|
|
7
|
+
const POLL_TIMEOUT_S = 25;
|
|
8
|
+
const POLL_BACKOFF_MS = 3000;
|
|
9
|
+
const TRANSIENT_BACKOFF_MS = 1000;
|
|
10
|
+
const TELEGRAM_PROVIDER = "telegram";
|
|
11
|
+
/**
|
|
12
|
+
* Telegram channel adapter — long-polls `getUpdates`, normalizes text messages
|
|
13
|
+
* to `GatewayInboundEnvelope`, and writes replies via `sendMessage`. Cursor
|
|
14
|
+
* (`update_id + 1`) is persisted to the state-store so a daemon restart never
|
|
15
|
+
* replays the last batch.
|
|
16
|
+
*
|
|
17
|
+
* Allowlists are default-deny: an empty (or missing) `allowedChatIds` /
|
|
18
|
+
* `allowedSenderIds` rejects every inbound message. This matches the security
|
|
19
|
+
* default in the third-party-gateway design doc.
|
|
20
|
+
*/
|
|
21
|
+
export function createTelegramChannel(opts) {
|
|
22
|
+
const channelType = TELEGRAM_PROVIDER;
|
|
23
|
+
const baseUrl = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
24
|
+
const splitAt = opts.splitAt && opts.splitAt > 0 ? opts.splitAt : DEFAULT_SPLIT_AT;
|
|
25
|
+
const allowedSenderIds = new Set((opts.allowedSenderIds ?? []).map((s) => String(s)));
|
|
26
|
+
const allowedChatIds = new Set((opts.allowedChatIds ?? []).map((s) => String(s)));
|
|
27
|
+
const fetchImpl = opts.fetchImpl ?? globalThis.fetch.bind(globalThis);
|
|
28
|
+
let botToken = opts.botToken;
|
|
29
|
+
let started = false;
|
|
30
|
+
/**
|
|
31
|
+
* C3: Redact the bot token from any log/error string. Telegram's Bot API
|
|
32
|
+
* embeds the token in the URL path, so fetch errors and JSON.parse failures
|
|
33
|
+
* routinely include it. Replace before any log.* call.
|
|
34
|
+
*/
|
|
35
|
+
function redactToken(input) {
|
|
36
|
+
if (!botToken || !input)
|
|
37
|
+
return input;
|
|
38
|
+
return input.split(botToken).join("***");
|
|
39
|
+
}
|
|
40
|
+
let stateStore = null;
|
|
41
|
+
let stopCallback = null;
|
|
42
|
+
// W11: captured during start() so send() can push lastSendAt to the
|
|
43
|
+
// gateway-tracked snapshot, not just the local statusSnapshot.
|
|
44
|
+
let liveSetStatus = null;
|
|
45
|
+
let statusSnapshot = {
|
|
46
|
+
channel: opts.id,
|
|
47
|
+
accountId: opts.accountId,
|
|
48
|
+
running: false,
|
|
49
|
+
connected: false,
|
|
50
|
+
reconnectAttempts: 0,
|
|
51
|
+
lastError: null,
|
|
52
|
+
provider: TELEGRAM_PROVIDER,
|
|
53
|
+
authorized: false,
|
|
54
|
+
};
|
|
55
|
+
function ensureState() {
|
|
56
|
+
if (!stateStore) {
|
|
57
|
+
stateStore = new GatewayStateStore(opts.id, {
|
|
58
|
+
...(opts.stateFile ? { override: opts.stateFile } : {}),
|
|
59
|
+
...(opts.stateDebounceMs !== undefined
|
|
60
|
+
? { debounceMs: opts.stateDebounceMs }
|
|
61
|
+
: {}),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return stateStore;
|
|
65
|
+
}
|
|
66
|
+
function loadTokenFromSecretIfNeeded() {
|
|
67
|
+
if (botToken)
|
|
68
|
+
return botToken;
|
|
69
|
+
const secret = loadGatewaySecret(opts.id, opts.secretFile);
|
|
70
|
+
if (secret && typeof secret.botToken === "string" && secret.botToken.length > 0) {
|
|
71
|
+
botToken = secret.botToken;
|
|
72
|
+
}
|
|
73
|
+
return botToken;
|
|
74
|
+
}
|
|
75
|
+
async function callApi(method, params, timeoutMs) {
|
|
76
|
+
if (!botToken)
|
|
77
|
+
throw new Error("telegram bot token not loaded");
|
|
78
|
+
const url = `${baseUrl}/bot${botToken}/${method}`;
|
|
79
|
+
let resp;
|
|
80
|
+
try {
|
|
81
|
+
resp = await fetchImpl(url, {
|
|
82
|
+
method: "POST",
|
|
83
|
+
headers: { "Content-Type": "application/json" },
|
|
84
|
+
body: JSON.stringify(params),
|
|
85
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
// C3: fetch errors often stringify the URL (which embeds the token).
|
|
90
|
+
// Re-raise with the token replaced.
|
|
91
|
+
const e = err;
|
|
92
|
+
const redacted = redactToken(e.message ?? String(err));
|
|
93
|
+
const next = new Error(redacted);
|
|
94
|
+
next.name = e.name ?? "Error";
|
|
95
|
+
throw next;
|
|
96
|
+
}
|
|
97
|
+
const json = (await resp.json());
|
|
98
|
+
return json;
|
|
99
|
+
}
|
|
100
|
+
function chatIdFromConversation(conversationId) {
|
|
101
|
+
if (conversationId.startsWith("telegram:user:")) {
|
|
102
|
+
return conversationId.slice("telegram:user:".length);
|
|
103
|
+
}
|
|
104
|
+
if (conversationId.startsWith("telegram:group:")) {
|
|
105
|
+
return conversationId.slice("telegram:group:".length);
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
function normalizeUpdate(update) {
|
|
110
|
+
const msg = update.message;
|
|
111
|
+
if (!msg)
|
|
112
|
+
return null;
|
|
113
|
+
const text = typeof msg.text === "string" ? msg.text : null;
|
|
114
|
+
if (text === null)
|
|
115
|
+
return null;
|
|
116
|
+
const from = msg.from;
|
|
117
|
+
if (!from)
|
|
118
|
+
return null;
|
|
119
|
+
const chat = msg.chat;
|
|
120
|
+
if (!chat)
|
|
121
|
+
return null;
|
|
122
|
+
const fromUserId = String(from.id);
|
|
123
|
+
const chatId = String(chat.id);
|
|
124
|
+
// W5: default-deny is the INTERSECTION — both chatId AND senderId must
|
|
125
|
+
// appear in their respective allowlists. An empty list rejects everyone.
|
|
126
|
+
// TODO: surface this rule in the dashboard help text (frontend).
|
|
127
|
+
if (!allowedChatIds.has(chatId))
|
|
128
|
+
return null;
|
|
129
|
+
if (!allowedSenderIds.has(fromUserId))
|
|
130
|
+
return null;
|
|
131
|
+
const isPrivate = chat.type === "private";
|
|
132
|
+
const conversationId = isPrivate
|
|
133
|
+
? `telegram:user:${chatId}`
|
|
134
|
+
: `telegram:group:${chatId}`;
|
|
135
|
+
const conversationKind = isPrivate ? "direct" : "group";
|
|
136
|
+
const senderName = from.username ??
|
|
137
|
+
[from.first_name].filter((s) => typeof s === "string" && s.length > 0)[0];
|
|
138
|
+
const sanitized = sanitizeUntrustedContent(text);
|
|
139
|
+
const messageId = `telegram:${chatId}:${msg.message_id}`;
|
|
140
|
+
return {
|
|
141
|
+
id: messageId,
|
|
142
|
+
channel: opts.id,
|
|
143
|
+
accountId: opts.accountId,
|
|
144
|
+
conversation: {
|
|
145
|
+
id: conversationId,
|
|
146
|
+
kind: conversationKind,
|
|
147
|
+
...(chat.title ? { title: chat.title } : {}),
|
|
148
|
+
},
|
|
149
|
+
sender: {
|
|
150
|
+
id: `telegram:user:${fromUserId}`,
|
|
151
|
+
...(senderName ? { name: senderName } : {}),
|
|
152
|
+
kind: "user",
|
|
153
|
+
},
|
|
154
|
+
text: sanitized,
|
|
155
|
+
raw: update,
|
|
156
|
+
replyTo: null,
|
|
157
|
+
mentioned: false,
|
|
158
|
+
receivedAt: Date.now(),
|
|
159
|
+
trace: { id: messageId, streamable: false },
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
async function pollLoop(ctx) {
|
|
163
|
+
const { abortSignal, log, emit, setStatus } = ctx;
|
|
164
|
+
liveSetStatus = setStatus;
|
|
165
|
+
const state = ensureState();
|
|
166
|
+
function markStatus(patch) {
|
|
167
|
+
statusSnapshot = { ...statusSnapshot, ...patch };
|
|
168
|
+
setStatus(patch);
|
|
169
|
+
}
|
|
170
|
+
if (!loadTokenFromSecretIfNeeded()) {
|
|
171
|
+
markStatus({
|
|
172
|
+
running: false,
|
|
173
|
+
connected: false,
|
|
174
|
+
authorized: false,
|
|
175
|
+
lastError: "missing_secret",
|
|
176
|
+
});
|
|
177
|
+
log.error("telegram missing bot token", { gatewayId: opts.id });
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
let offset = 0;
|
|
181
|
+
const cursor = state.getCursor();
|
|
182
|
+
if (cursor) {
|
|
183
|
+
const parsed = Number(cursor);
|
|
184
|
+
if (Number.isFinite(parsed))
|
|
185
|
+
offset = parsed;
|
|
186
|
+
}
|
|
187
|
+
markStatus({
|
|
188
|
+
running: true,
|
|
189
|
+
connected: true,
|
|
190
|
+
authorized: true,
|
|
191
|
+
reconnectAttempts: 0,
|
|
192
|
+
lastError: null,
|
|
193
|
+
lastStartAt: Date.now(),
|
|
194
|
+
});
|
|
195
|
+
log.info("telegram poll loop starting", { gatewayId: opts.id, offset });
|
|
196
|
+
let stopped = false;
|
|
197
|
+
const onAbort = () => {
|
|
198
|
+
stopped = true;
|
|
199
|
+
};
|
|
200
|
+
abortSignal.addEventListener("abort", onAbort, { once: true });
|
|
201
|
+
stopCallback = () => {
|
|
202
|
+
stopped = true;
|
|
203
|
+
};
|
|
204
|
+
while (!stopped && !abortSignal.aborted) {
|
|
205
|
+
try {
|
|
206
|
+
const resp = await callApi("getUpdates", {
|
|
207
|
+
offset,
|
|
208
|
+
timeout: POLL_TIMEOUT_S,
|
|
209
|
+
allowed_updates: ["message"],
|
|
210
|
+
}, (POLL_TIMEOUT_S + 15) * 1000);
|
|
211
|
+
markStatus({ lastPollAt: Date.now() });
|
|
212
|
+
if (!resp.ok) {
|
|
213
|
+
log.warn("telegram getUpdates non-ok", {
|
|
214
|
+
description: redactToken(resp.description ?? ""),
|
|
215
|
+
});
|
|
216
|
+
markStatus({ lastError: redactToken(resp.description ?? "getUpdates failed") });
|
|
217
|
+
await sleep(POLL_BACKOFF_MS, abortSignal);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
const updates = resp.result ?? [];
|
|
221
|
+
if (updates.length === 0)
|
|
222
|
+
continue;
|
|
223
|
+
// W1: persist cursor only AFTER all emits return cleanly. If emit
|
|
224
|
+
// throws, leave the cursor untouched so the same batch retries on
|
|
225
|
+
// the next poll instead of being silently dropped.
|
|
226
|
+
let maxId = offset - 1;
|
|
227
|
+
for (const u of updates) {
|
|
228
|
+
if (u.update_id > maxId)
|
|
229
|
+
maxId = u.update_id;
|
|
230
|
+
}
|
|
231
|
+
let emitFailed = false;
|
|
232
|
+
for (const update of updates) {
|
|
233
|
+
const normalized = normalizeUpdate(update);
|
|
234
|
+
if (!normalized)
|
|
235
|
+
continue;
|
|
236
|
+
markStatus({ lastInboundAt: Date.now() });
|
|
237
|
+
const envelope = { message: normalized };
|
|
238
|
+
try {
|
|
239
|
+
await emit(envelope);
|
|
240
|
+
}
|
|
241
|
+
catch (err) {
|
|
242
|
+
emitFailed = true;
|
|
243
|
+
log.error("telegram emit threw — leaving cursor unchanged", {
|
|
244
|
+
err: redactToken(String(err)),
|
|
245
|
+
});
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (!emitFailed) {
|
|
250
|
+
offset = maxId + 1;
|
|
251
|
+
state.update({ cursor: String(offset) });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
catch (err) {
|
|
255
|
+
if (stopped || abortSignal.aborted)
|
|
256
|
+
break;
|
|
257
|
+
const name = err?.name ?? "";
|
|
258
|
+
if (name === "AbortError" || name === "TimeoutError") {
|
|
259
|
+
log.warn("telegram poll transient", { name });
|
|
260
|
+
await sleep(TRANSIENT_BACKOFF_MS, abortSignal);
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
log.error("telegram poll failed", { err: redactToken(String(err)) });
|
|
264
|
+
markStatus({ lastError: redactToken(String(err)) });
|
|
265
|
+
await sleep(POLL_BACKOFF_MS, abortSignal);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
markStatus({
|
|
269
|
+
running: false,
|
|
270
|
+
connected: false,
|
|
271
|
+
lastStopAt: Date.now(),
|
|
272
|
+
});
|
|
273
|
+
try {
|
|
274
|
+
state.flush();
|
|
275
|
+
}
|
|
276
|
+
catch (e) {
|
|
277
|
+
log.warn("state-flush-on-stop failed", { error: String(e) });
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
const adapter = {
|
|
281
|
+
id: opts.id,
|
|
282
|
+
type: channelType,
|
|
283
|
+
async start(ctx) {
|
|
284
|
+
if (started)
|
|
285
|
+
throw new Error("already started");
|
|
286
|
+
started = true;
|
|
287
|
+
await pollLoop(ctx);
|
|
288
|
+
},
|
|
289
|
+
async stop(_ctx) {
|
|
290
|
+
if (stopCallback) {
|
|
291
|
+
stopCallback();
|
|
292
|
+
stopCallback = null;
|
|
293
|
+
}
|
|
294
|
+
try {
|
|
295
|
+
stateStore?.flush();
|
|
296
|
+
}
|
|
297
|
+
catch (e) {
|
|
298
|
+
// W7: log flush failures at stop — previously swallowed silently.
|
|
299
|
+
console.warn("[telegram] state-flush-on-stop failed", String(e));
|
|
300
|
+
}
|
|
301
|
+
},
|
|
302
|
+
async send(ctx) {
|
|
303
|
+
const { message, log } = ctx;
|
|
304
|
+
if (!loadTokenFromSecretIfNeeded()) {
|
|
305
|
+
throw new Error("telegram bot token not loaded");
|
|
306
|
+
}
|
|
307
|
+
const chatId = chatIdFromConversation(message.conversationId);
|
|
308
|
+
if (!chatId) {
|
|
309
|
+
throw new Error(`telegram send: unrecognized conversationId "${message.conversationId}"`);
|
|
310
|
+
}
|
|
311
|
+
const chunks = splitText(message.text, splitAt);
|
|
312
|
+
let lastMessageId = null;
|
|
313
|
+
for (const chunk of chunks) {
|
|
314
|
+
const resp = await callApi("sendMessage", {
|
|
315
|
+
chat_id: chatId,
|
|
316
|
+
text: chunk,
|
|
317
|
+
disable_web_page_preview: true,
|
|
318
|
+
}, 15_000);
|
|
319
|
+
if (!resp.ok) {
|
|
320
|
+
log.warn("telegram sendMessage non-ok", {
|
|
321
|
+
description: redactToken(resp.description ?? ""),
|
|
322
|
+
});
|
|
323
|
+
throw new Error(`telegram sendMessage failed: ${redactToken(resp.description ?? "unknown")}`);
|
|
324
|
+
}
|
|
325
|
+
if (resp.result?.message_id !== undefined) {
|
|
326
|
+
lastMessageId = `telegram:${chatId}:${resp.result.message_id}`;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
const sendAt = Date.now();
|
|
330
|
+
statusSnapshot = { ...statusSnapshot, lastSendAt: sendAt };
|
|
331
|
+
// W11: push to the gateway snapshot too — the dashboard reads this.
|
|
332
|
+
if (liveSetStatus)
|
|
333
|
+
liveSetStatus({ lastSendAt: sendAt });
|
|
334
|
+
return { providerMessageId: lastMessageId };
|
|
335
|
+
},
|
|
336
|
+
async typing(ctx) {
|
|
337
|
+
if (!loadTokenFromSecretIfNeeded())
|
|
338
|
+
return;
|
|
339
|
+
const chatId = chatIdFromConversation(ctx.conversationId);
|
|
340
|
+
if (!chatId)
|
|
341
|
+
return;
|
|
342
|
+
try {
|
|
343
|
+
await callApi("sendChatAction", { chat_id: chatId, action: "typing" }, 10_000);
|
|
344
|
+
}
|
|
345
|
+
catch (err) {
|
|
346
|
+
ctx.log.warn("telegram typing failed", { err: redactToken(String(err)) });
|
|
347
|
+
}
|
|
348
|
+
},
|
|
349
|
+
status() {
|
|
350
|
+
return { ...statusSnapshot };
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
return adapter;
|
|
354
|
+
}
|
|
355
|
+
function sleep(ms, signal) {
|
|
356
|
+
return new Promise((resolve) => {
|
|
357
|
+
if (signal?.aborted) {
|
|
358
|
+
resolve();
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
const timer = setTimeout(() => {
|
|
362
|
+
signal?.removeEventListener("abort", onAbort);
|
|
363
|
+
resolve();
|
|
364
|
+
}, ms);
|
|
365
|
+
const onAbort = () => {
|
|
366
|
+
clearTimeout(timer);
|
|
367
|
+
resolve();
|
|
368
|
+
};
|
|
369
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
370
|
+
});
|
|
371
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Split a long message into chunks <= `limit` characters each. Prefers to cut
|
|
3
|
+
* on newline boundaries so multi-paragraph replies don't fragment mid-line.
|
|
4
|
+
*
|
|
5
|
+
* Shared by third-party channel adapters (Telegram, WeChat) which both have a
|
|
6
|
+
* per-message size cap from upstream and no native streaming. WeChat caller
|
|
7
|
+
* passes a smaller `limit` (~1800), Telegram a larger one (~4000, since the
|
|
8
|
+
* raw Telegram limit is 4096).
|
|
9
|
+
*
|
|
10
|
+
* Empty input returns `[""]` so callers can iterate uniformly without a length
|
|
11
|
+
* check.
|
|
12
|
+
*/
|
|
13
|
+
export declare function splitText(text: string, limit: number): string[];
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Split a long message into chunks <= `limit` characters each. Prefers to cut
|
|
3
|
+
* on newline boundaries so multi-paragraph replies don't fragment mid-line.
|
|
4
|
+
*
|
|
5
|
+
* Shared by third-party channel adapters (Telegram, WeChat) which both have a
|
|
6
|
+
* per-message size cap from upstream and no native streaming. WeChat caller
|
|
7
|
+
* passes a smaller `limit` (~1800), Telegram a larger one (~4000, since the
|
|
8
|
+
* raw Telegram limit is 4096).
|
|
9
|
+
*
|
|
10
|
+
* Empty input returns `[""]` so callers can iterate uniformly without a length
|
|
11
|
+
* check.
|
|
12
|
+
*/
|
|
13
|
+
export function splitText(text, limit) {
|
|
14
|
+
if (limit <= 0)
|
|
15
|
+
return [text];
|
|
16
|
+
if (text.length === 0)
|
|
17
|
+
return [""];
|
|
18
|
+
if (text.length <= limit)
|
|
19
|
+
return [text];
|
|
20
|
+
const out = [];
|
|
21
|
+
let remaining = text;
|
|
22
|
+
while (remaining.length > limit) {
|
|
23
|
+
let cut = remaining.lastIndexOf("\n", limit);
|
|
24
|
+
if (cut <= 0)
|
|
25
|
+
cut = limit;
|
|
26
|
+
out.push(remaining.slice(0, cut));
|
|
27
|
+
// Drop the leading newline so the next chunk doesn't start with a blank line.
|
|
28
|
+
remaining = remaining.slice(cut).replace(/^\n/, "");
|
|
29
|
+
}
|
|
30
|
+
if (remaining.length > 0)
|
|
31
|
+
out.push(remaining);
|
|
32
|
+
return out;
|
|
33
|
+
}
|