@cjhyy/code-shell-chat 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +416 -0
  3. package/dist/adapter-factory.d.ts +30 -0
  4. package/dist/adapter-factory.js +198 -0
  5. package/dist/attachments.d.ts +13 -0
  6. package/dist/attachments.js +74 -0
  7. package/dist/channel.d.ts +112 -0
  8. package/dist/channel.js +60 -0
  9. package/dist/chat-gateway.d.ts +58 -0
  10. package/dist/chat-gateway.js +232 -0
  11. package/dist/cli.d.ts +2 -0
  12. package/dist/cli.js +259 -0
  13. package/dist/codeshell.d.ts +6 -0
  14. package/dist/codeshell.js +6 -0
  15. package/dist/config.d.ts +125 -0
  16. package/dist/config.js +469 -0
  17. package/dist/delivery-queue.d.ts +93 -0
  18. package/dist/delivery-queue.js +401 -0
  19. package/dist/desktop-control-client.d.ts +51 -0
  20. package/dist/desktop-control-client.js +309 -0
  21. package/dist/dingtalk.d.ts +19 -0
  22. package/dist/dingtalk.js +93 -0
  23. package/dist/discord.d.ts +23 -0
  24. package/dist/discord.js +134 -0
  25. package/dist/gateway.d.ts +34 -0
  26. package/dist/gateway.js +287 -0
  27. package/dist/index.d.ts +13 -0
  28. package/dist/index.js +13 -0
  29. package/dist/ingress.d.ts +8 -0
  30. package/dist/ingress.js +61 -0
  31. package/dist/instance-lock.d.ts +24 -0
  32. package/dist/instance-lock.js +100 -0
  33. package/dist/lark.d.ts +20 -0
  34. package/dist/lark.js +184 -0
  35. package/dist/lifecycle.d.ts +7 -0
  36. package/dist/lifecycle.js +16 -0
  37. package/dist/line.d.ts +17 -0
  38. package/dist/line.js +123 -0
  39. package/dist/matrix.d.ts +21 -0
  40. package/dist/matrix.js +208 -0
  41. package/dist/mattermost.d.ts +18 -0
  42. package/dist/mattermost.js +170 -0
  43. package/dist/media.d.ts +39 -0
  44. package/dist/media.js +223 -0
  45. package/dist/notification-progress.d.ts +46 -0
  46. package/dist/notification-progress.js +299 -0
  47. package/dist/notification-relay.d.ts +43 -0
  48. package/dist/notification-relay.js +285 -0
  49. package/dist/platform-canary.d.ts +22 -0
  50. package/dist/platform-canary.js +55 -0
  51. package/dist/protocol.d.ts +102 -0
  52. package/dist/protocol.js +1 -0
  53. package/dist/service-manager.d.ts +47 -0
  54. package/dist/service-manager.js +222 -0
  55. package/dist/slack.d.ts +17 -0
  56. package/dist/slack.js +136 -0
  57. package/dist/teams.d.ts +26 -0
  58. package/dist/teams.js +193 -0
  59. package/dist/telegram.d.ts +33 -0
  60. package/dist/telegram.js +263 -0
  61. package/dist/webhook.d.ts +26 -0
  62. package/dist/webhook.js +91 -0
  63. package/dist/wechat-login.d.ts +21 -0
  64. package/dist/wechat-login.js +97 -0
  65. package/dist/wechat-storage.d.ts +61 -0
  66. package/dist/wechat-storage.js +251 -0
  67. package/dist/wechat.d.ts +129 -0
  68. package/dist/wechat.js +1082 -0
  69. package/dist/wecom.d.ts +16 -0
  70. package/dist/wecom.js +149 -0
  71. package/dist/whatsapp.d.ts +27 -0
  72. package/dist/whatsapp.js +218 -0
  73. package/package.json +123 -0
@@ -0,0 +1,13 @@
1
+ import type { ChatAttachment } from "./channel.js";
2
+ import type { PetChatInputAttachment } from "./protocol.js";
3
+ export declare const DEFAULT_MAX_CHAT_ATTACHMENTS = 4;
4
+ export declare const DEFAULT_MAX_CHAT_ATTACHMENT_BYTES: number;
5
+ export declare const DEFAULT_MAX_CHAT_ATTACHMENTS_TOTAL_BYTES: number;
6
+ export interface MaterializeChatAttachmentsOptions {
7
+ maxAttachments?: number;
8
+ maxAttachmentBytes?: number;
9
+ maxTotalBytes?: number;
10
+ timeoutMs?: number;
11
+ }
12
+ /** Fetch lazy channel media under bounded count, per-file, total, and time limits. */
13
+ export declare function materializeChatAttachments(attachments: readonly ChatAttachment[] | undefined, options?: MaterializeChatAttachmentsOptions): Promise<PetChatInputAttachment[]>;
@@ -0,0 +1,74 @@
1
+ export const DEFAULT_MAX_CHAT_ATTACHMENTS = 4;
2
+ export const DEFAULT_MAX_CHAT_ATTACHMENT_BYTES = 10 * 1024 * 1024;
3
+ export const DEFAULT_MAX_CHAT_ATTACHMENTS_TOTAL_BYTES = 20 * 1024 * 1024;
4
+ /** Fetch lazy channel media under bounded count, per-file, total, and time limits. */
5
+ export async function materializeChatAttachments(attachments, options = {}) {
6
+ const input = attachments ?? [];
7
+ const maxAttachments = options.maxAttachments ?? DEFAULT_MAX_CHAT_ATTACHMENTS;
8
+ const maxAttachmentBytes = options.maxAttachmentBytes ?? DEFAULT_MAX_CHAT_ATTACHMENT_BYTES;
9
+ const maxTotalBytes = options.maxTotalBytes ?? DEFAULT_MAX_CHAT_ATTACHMENTS_TOTAL_BYTES;
10
+ const timeoutMs = options.timeoutMs ?? 30_000;
11
+ if (input.length > maxAttachments) {
12
+ throw new Error(`单条消息最多接收 ${maxAttachments} 个附件`);
13
+ }
14
+ const seen = new Set();
15
+ const output = [];
16
+ let total = 0;
17
+ for (const attachment of input) {
18
+ if (!attachment.id?.trim() || seen.has(attachment.id)) {
19
+ throw new Error("附件 ID 为空或重复");
20
+ }
21
+ seen.add(attachment.id);
22
+ if (attachment.size !== undefined &&
23
+ (!Number.isSafeInteger(attachment.size) ||
24
+ attachment.size < 0 ||
25
+ attachment.size > maxAttachmentBytes)) {
26
+ throw new Error(`附件 ${attachment.name ?? attachment.id} 超过大小限制`);
27
+ }
28
+ const bytes = new Uint8Array(await attachment.load(AbortSignal.timeout(timeoutMs)));
29
+ if (bytes.byteLength > maxAttachmentBytes) {
30
+ throw new Error(`附件 ${attachment.name ?? attachment.id} 超过大小限制`);
31
+ }
32
+ if (attachment.size !== undefined && attachment.size !== bytes.byteLength) {
33
+ throw new Error(`附件 ${attachment.name ?? attachment.id} 实际大小与声明不一致`);
34
+ }
35
+ total += bytes.byteLength;
36
+ if (total > maxTotalBytes)
37
+ throw new Error("附件总大小超过限制");
38
+ const mimeType = attachment.kind === "image"
39
+ ? (detectImageMime(bytes) ?? attachment.mimeType)
40
+ : attachment.mimeType;
41
+ output.push({
42
+ id: attachment.id,
43
+ kind: attachment.kind,
44
+ ...(attachment.name ? { name: attachment.name } : {}),
45
+ ...(mimeType ? { mimeType } : {}),
46
+ size: bytes.byteLength,
47
+ dataBase64: Buffer.from(bytes).toString("base64"),
48
+ });
49
+ }
50
+ return output;
51
+ }
52
+ function detectImageMime(bytes) {
53
+ if (bytes.length >= 8 &&
54
+ bytes[0] === 0x89 &&
55
+ bytes[1] === 0x50 &&
56
+ bytes[2] === 0x4e &&
57
+ bytes[3] === 0x47 &&
58
+ bytes[4] === 0x0d &&
59
+ bytes[5] === 0x0a &&
60
+ bytes[6] === 0x1a &&
61
+ bytes[7] === 0x0a) {
62
+ return "image/png";
63
+ }
64
+ if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
65
+ return "image/jpeg";
66
+ }
67
+ const ascii = (start, end) => String.fromCharCode(...bytes.subarray(start, end));
68
+ if (bytes.length >= 6 && ["GIF87a", "GIF89a"].includes(ascii(0, 6)))
69
+ return "image/gif";
70
+ if (bytes.length >= 12 && ascii(0, 4) === "RIFF" && ascii(8, 12) === "WEBP") {
71
+ return "image/webp";
72
+ }
73
+ return undefined;
74
+ }
@@ -0,0 +1,112 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ export type ChatAttachmentKind = "image" | "file" | "audio" | "video";
3
+ export type ChannelButtonCapability = "native" | "link";
4
+ /**
5
+ * JSON-safe adapter contract shared with the Gateway and Desktop/Pet bridge.
6
+ * Capabilities describe what this implementation actually supports, not every
7
+ * feature offered by the upstream platform.
8
+ */
9
+ export interface ChannelCapabilities {
10
+ inbound: {
11
+ text: true;
12
+ attachments: readonly ChatAttachmentKind[];
13
+ };
14
+ outbound: {
15
+ text: true;
16
+ /**
17
+ * Whether the adapter supports an owner-addressed send outside the current
18
+ * reply call. Platform prerequisites may still apply; for example WeChat
19
+ * needs a persisted inbound context token and exposes dynamic readiness in
20
+ * the Desktop status instead of pretending tokenless push is guaranteed.
21
+ */
22
+ proactive: boolean;
23
+ /** Whether send() works on a fresh adapter without run() being active. */
24
+ direct: boolean;
25
+ /** Maximum complete GatewayReply text before transport-safe chunking. */
26
+ maxTextLength?: number;
27
+ /** Native interactive button, or a normal labelled URL rendered in text/markdown. */
28
+ button: ChannelButtonCapability;
29
+ attachments: readonly ChatAttachmentKind[];
30
+ maxAttachments?: number;
31
+ maxAttachmentBytes?: number;
32
+ };
33
+ }
34
+ /** Built-in adapter capability matrix; keep this aligned with adapter send/ingress tests. */
35
+ export declare const BUILTIN_CHANNEL_CAPABILITIES: {
36
+ readonly telegram: ChannelCapabilities;
37
+ readonly discord: ChannelCapabilities;
38
+ readonly slack: ChannelCapabilities;
39
+ readonly lark: ChannelCapabilities;
40
+ readonly dingtalk: ChannelCapabilities;
41
+ readonly wecom: ChannelCapabilities;
42
+ readonly wechat: ChannelCapabilities;
43
+ readonly matrix: ChannelCapabilities;
44
+ readonly mattermost: ChannelCapabilities;
45
+ readonly line: ChannelCapabilities;
46
+ readonly whatsapp: ChannelCapabilities;
47
+ readonly teams: ChannelCapabilities;
48
+ };
49
+ /**
50
+ * Lazy inbound attachment. Adapters expose metadata first; bytes are only
51
+ * fetched after allowlist middleware has accepted the sender.
52
+ */
53
+ export interface ChatAttachment {
54
+ id: string;
55
+ kind: ChatAttachmentKind;
56
+ name?: string;
57
+ mimeType?: string;
58
+ size?: number;
59
+ load(signal?: AbortSignal): Promise<Uint8Array>;
60
+ }
61
+ export interface ChannelMessage {
62
+ channel: string;
63
+ target: string;
64
+ senderId: string;
65
+ text: string;
66
+ attachments?: readonly ChatAttachment[];
67
+ messageId?: string;
68
+ threadId?: string;
69
+ metadata?: Readonly<Record<string, unknown>>;
70
+ }
71
+ export interface OutgoingMessage {
72
+ text: string;
73
+ title?: string;
74
+ button?: {
75
+ text: string;
76
+ url: string;
77
+ };
78
+ /** Materialized outbound media bytes. Adapters may support a bounded subset. */
79
+ attachments?: readonly OutgoingAttachment[];
80
+ }
81
+ export interface OutgoingAttachment {
82
+ kind: ChatAttachmentKind;
83
+ name: string;
84
+ mimeType: string;
85
+ data: Uint8Array;
86
+ }
87
+ export type ChannelMessageHandler = (message: ChannelMessage) => Promise<void>;
88
+ export interface ChannelAdapter {
89
+ readonly channel: string;
90
+ readonly capabilities?: ChannelCapabilities;
91
+ /**
92
+ * True only when one visible platform request atomically publishes both the
93
+ * text and every attachment. Retry coordinators default to separate sends;
94
+ * optimistic combination would duplicate accepted text after a media error.
95
+ */
96
+ readonly combinesTextAndAttachmentsAtomically?: boolean;
97
+ /** @deprecated Use capabilities.outbound.attachments or supportsOutgoingAttachment(). */
98
+ readonly supportsOutgoingAttachments?: boolean;
99
+ run(handler: ChannelMessageHandler, signal: AbortSignal): Promise<void>;
100
+ send(target: string, message: OutgoingMessage): Promise<void>;
101
+ }
102
+ export declare function channelCapabilities(adapter: ChannelAdapter): ChannelCapabilities;
103
+ export declare function supportsOutgoingAttachment(adapter: ChannelAdapter, kind: ChatAttachmentKind): boolean;
104
+ export interface ChatCommandDefinition {
105
+ name: string;
106
+ description: string;
107
+ }
108
+ export interface WebhookChannelAdapter extends ChannelAdapter {
109
+ readonly webhookPath: string;
110
+ handleWebhook(request: IncomingMessage, response: ServerResponse, handler: ChannelMessageHandler, maxBodyBytes: number): Promise<void>;
111
+ }
112
+ export declare function isWebhookChannelAdapter(adapter: ChannelAdapter): adapter is WebhookChannelAdapter;
@@ -0,0 +1,60 @@
1
+ const ALL_ATTACHMENT_KINDS = ["image", "file", "audio", "video"];
2
+ const TEN_MIB = 10 * 1024 * 1024;
3
+ function capabilities(button, inboundAttachments = [], outboundAttachments = [], options = {}) {
4
+ return {
5
+ inbound: { text: true, attachments: inboundAttachments },
6
+ outbound: {
7
+ text: true,
8
+ proactive: options.proactive ?? true,
9
+ direct: options.direct ?? false,
10
+ maxTextLength: options.maxTextLength ?? 8_000,
11
+ button,
12
+ attachments: outboundAttachments,
13
+ ...(outboundAttachments.length > 0
14
+ ? { maxAttachments: 4, maxAttachmentBytes: options.maxAttachmentBytes ?? TEN_MIB }
15
+ : {}),
16
+ },
17
+ };
18
+ }
19
+ /** Built-in adapter capability matrix; keep this aligned with adapter send/ingress tests. */
20
+ export const BUILTIN_CHANNEL_CAPABILITIES = {
21
+ telegram: capabilities("native", ALL_ATTACHMENT_KINDS, ALL_ATTACHMENT_KINDS, { direct: true }),
22
+ discord: capabilities("native", ALL_ATTACHMENT_KINDS, ALL_ATTACHMENT_KINDS),
23
+ slack: capabilities("native", ALL_ATTACHMENT_KINDS, ALL_ATTACHMENT_KINDS, { direct: true }),
24
+ lark: capabilities("native", ALL_ATTACHMENT_KINDS, ALL_ATTACHMENT_KINDS, { direct: true }),
25
+ dingtalk: capabilities("native"),
26
+ wecom: capabilities("link", ["image", "file", "video"], ALL_ATTACHMENT_KINDS),
27
+ // A fresh adapter can reuse a persisted inbound context_token. Stale tokens
28
+ // are durably evicted; tokenless retry is best-effort, not a protocol promise.
29
+ wechat: capabilities("link", ALL_ATTACHMENT_KINDS, ALL_ATTACHMENT_KINDS, {
30
+ direct: true,
31
+ }),
32
+ matrix: capabilities("link", ALL_ATTACHMENT_KINDS, ALL_ATTACHMENT_KINDS, { direct: true }),
33
+ mattermost: capabilities("link", ALL_ATTACHMENT_KINDS, ALL_ATTACHMENT_KINDS, { direct: true }),
34
+ line: capabilities("native", ALL_ATTACHMENT_KINDS, [], { direct: true }),
35
+ whatsapp: capabilities("native", ALL_ATTACHMENT_KINDS, ALL_ATTACHMENT_KINDS, { direct: true }),
36
+ // Bot Framework supports inline data-URI pictures. General file/audio/video
37
+ // delivery needs a public URL or the Teams file-consent/Graph flow, neither
38
+ // of which this credential shape currently configures.
39
+ teams: capabilities("link", ALL_ATTACHMENT_KINDS, ["image"], {
40
+ maxAttachmentBytes: 1024 * 1024,
41
+ }),
42
+ };
43
+ const DEFAULT_CHANNEL_CAPABILITIES = capabilities("link");
44
+ export function channelCapabilities(adapter) {
45
+ if (adapter.capabilities)
46
+ return adapter.capabilities;
47
+ // Preserve compatibility for third-party adapters built against the old
48
+ // boolean contract. Built-in adapters always publish the granular shape.
49
+ if (adapter.supportsOutgoingAttachments) {
50
+ return capabilities("link", [], ALL_ATTACHMENT_KINDS);
51
+ }
52
+ return DEFAULT_CHANNEL_CAPABILITIES;
53
+ }
54
+ export function supportsOutgoingAttachment(adapter, kind) {
55
+ return channelCapabilities(adapter).outbound.attachments.includes(kind);
56
+ }
57
+ export function isWebhookChannelAdapter(adapter) {
58
+ return ("webhookPath" in adapter &&
59
+ typeof adapter.webhookPath === "string");
60
+ }
@@ -0,0 +1,58 @@
1
+ import type { ChannelAdapter, ChannelMessage, OutgoingMessage } from "./channel.js";
2
+ import { type DeliveryQueueConfig } from "./delivery-queue.js";
3
+ import { type WebhookIngressConfig } from "./webhook.js";
4
+ export interface ChatContext {
5
+ readonly message: ChannelMessage;
6
+ readonly adapter: ChannelAdapter;
7
+ reply(message: OutgoingMessage): Promise<void>;
8
+ }
9
+ export type ChatMiddleware = (context: ChatContext, next: () => Promise<void>) => Promise<void>;
10
+ export interface ChatGatewayOptions {
11
+ adapters: ChannelAdapter[];
12
+ webhook?: Partial<WebhookIngressConfig>;
13
+ delivery?: Partial<DeliveryQueueConfig>;
14
+ adapterRestart?: Partial<{
15
+ baseMs: number;
16
+ maxMs: number;
17
+ }>;
18
+ onError?: (error: unknown, message: ChannelMessage) => void;
19
+ onAdapterState?: (state: AdapterRuntimeState) => void;
20
+ }
21
+ export interface AdapterRuntimeState {
22
+ id: string;
23
+ channel: string;
24
+ state: "starting" | "running" | "backoff" | "stopped";
25
+ attempts: number;
26
+ error?: string;
27
+ }
28
+ export interface ChatAllowlistRule {
29
+ targetIds: Iterable<string>;
30
+ userIds?: Iterable<string>;
31
+ }
32
+ /**
33
+ * Transport-agnostic chat runtime. It owns channel lifecycles and webhook ingress,
34
+ * while application behavior is supplied entirely through middleware.
35
+ */
36
+ export declare class ChatGateway {
37
+ private readonly middleware;
38
+ private readonly adapters;
39
+ private readonly webhook;
40
+ private readonly delivery;
41
+ private readonly adapterRestart;
42
+ private readonly onError;
43
+ private readonly onAdapterState?;
44
+ private readonly adapterStates;
45
+ private queue?;
46
+ private startedAt?;
47
+ constructor(options: ChatGatewayOptions);
48
+ use(middleware: ChatMiddleware): this;
49
+ dispatch(adapter: ChannelAdapter, message: ChannelMessage): Promise<void>;
50
+ run(signal: AbortSignal): Promise<void>;
51
+ healthSnapshot(): Record<string, unknown>;
52
+ private superviseAdapter;
53
+ private setAdapterState;
54
+ }
55
+ /** Deny-by-default allowlist middleware for multi-channel gateways. */
56
+ export declare function createAllowlistMiddleware(rules: Readonly<Record<string, ChatAllowlistRule>>): ChatMiddleware;
57
+ /** Fixed-window per-sender limiter; rejected messages never reach expensive middleware. */
58
+ export declare function createRateLimitMiddleware(maxMessages: number, windowMs?: number, onLimited?: (message: ChannelMessage) => void): ChatMiddleware;
@@ -0,0 +1,232 @@
1
+ import { isWebhookChannelAdapter } from "./channel.js";
2
+ import { DeliveryQueue, UnroutableDeliveryError, } from "./delivery-queue.js";
3
+ import { waitForAbort } from "./lifecycle.js";
4
+ import { WebhookIngressServer, } from "./webhook.js";
5
+ const DEFAULT_WEBHOOK_CONFIG = {
6
+ host: "127.0.0.1",
7
+ port: 8787,
8
+ maxBodyBytes: 1_048_576,
9
+ healthPath: "/healthz",
10
+ readyPath: "/readyz",
11
+ requestTimeoutMs: 15_000,
12
+ };
13
+ const DEFAULT_DELIVERY_CONFIG = {
14
+ maxPending: 1_000,
15
+ maxConcurrent: 4,
16
+ maxPerTarget: 1,
17
+ retryBaseMs: 1_000,
18
+ retryMaxMs: 30_000,
19
+ completedTtlMs: 7 * 24 * 60 * 60 * 1_000,
20
+ };
21
+ /**
22
+ * Transport-agnostic chat runtime. It owns channel lifecycles and webhook ingress,
23
+ * while application behavior is supplied entirely through middleware.
24
+ */
25
+ export class ChatGateway {
26
+ middleware = [];
27
+ adapters;
28
+ webhook;
29
+ delivery;
30
+ adapterRestart;
31
+ onError;
32
+ onAdapterState;
33
+ adapterStates = new Map();
34
+ queue;
35
+ startedAt;
36
+ constructor(options) {
37
+ this.adapters = [...options.adapters];
38
+ this.webhook = { ...DEFAULT_WEBHOOK_CONFIG, ...options.webhook };
39
+ this.delivery = { ...DEFAULT_DELIVERY_CONFIG, ...options.delivery };
40
+ this.adapterRestart = {
41
+ baseMs: options.adapterRestart?.baseMs ?? 1_000,
42
+ maxMs: options.adapterRestart?.maxMs ?? 30_000,
43
+ };
44
+ this.onAdapterState = options.onAdapterState;
45
+ this.onError =
46
+ options.onError ??
47
+ ((error, message) => {
48
+ console.error(`[chat] ${message.channel} message failed: ${error instanceof Error ? error.message : String(error)}`);
49
+ });
50
+ }
51
+ use(middleware) {
52
+ this.middleware.push(middleware);
53
+ return this;
54
+ }
55
+ async dispatch(adapter, message) {
56
+ if (message.channel !== adapter.channel) {
57
+ throw new Error(`Adapter ${adapter.channel} emitted a message for channel ${message.channel}`);
58
+ }
59
+ const context = {
60
+ message,
61
+ adapter,
62
+ reply: (outgoing) => adapter.send(message.target, outgoing),
63
+ };
64
+ let index = -1;
65
+ const next = async (position) => {
66
+ if (position <= index)
67
+ throw new Error("Chat middleware called next() more than once");
68
+ index = position;
69
+ const current = this.middleware[position];
70
+ if (current)
71
+ await current(context, () => next(position + 1));
72
+ };
73
+ await next(0);
74
+ }
75
+ async run(signal) {
76
+ if (this.startedAt)
77
+ throw new Error("ChatGateway is already running");
78
+ this.startedAt = Date.now();
79
+ const ownedAbort = new AbortController();
80
+ const stopOwned = () => ownedAbort.abort();
81
+ if (signal.aborted)
82
+ ownedAbort.abort();
83
+ else
84
+ signal.addEventListener("abort", stopOwned, { once: true });
85
+ const webhooks = [];
86
+ const adapterById = new Map();
87
+ // Key adapters by a per-channel ordinal (channel:0, channel:1, …) rather
88
+ // than the global array index. Delivery-queue records persist this id and
89
+ // are replayed on the next run; a global index would shift when an
90
+ // unrelated channel is removed from config, orphaning durable messages
91
+ // against an id that no longer maps to their adapter.
92
+ const perChannelCount = new Map();
93
+ for (const adapter of this.adapters) {
94
+ const ordinal = perChannelCount.get(adapter.channel) ?? 0;
95
+ perChannelCount.set(adapter.channel, ordinal + 1);
96
+ adapterById.set(`${adapter.channel}:${ordinal}`, adapter);
97
+ }
98
+ const queue = new DeliveryQueue(this.delivery, async (adapterId, message) => {
99
+ const adapter = adapterById.get(adapterId);
100
+ if (!adapter) {
101
+ // A durable record survived a config change that removed its adapter.
102
+ // Signal unroutable so the queue drops it instead of retrying forever.
103
+ throw new UnroutableDeliveryError(`Chat adapter no longer exists: ${adapterId}`);
104
+ }
105
+ await this.dispatch(adapter, message);
106
+ }, this.onError);
107
+ this.queue = queue;
108
+ await queue.start();
109
+ const runners = [];
110
+ for (const [adapterId, adapter] of adapterById) {
111
+ const handler = (message) => queue.enqueue(adapterId, message).then(() => undefined);
112
+ if (isWebhookChannelAdapter(adapter))
113
+ webhooks.push({ adapter, handler });
114
+ runners.push(this.superviseAdapter(adapterId, adapter, handler, ownedAbort.signal));
115
+ }
116
+ // Only bind the ingress host:port when a channel actually needs a webhook
117
+ // route. A polling-only config (e.g. Telegram/Discord) must not open an
118
+ // HTTP listener — doing so unexpectedly exposes a port and fails startup
119
+ // outright when that port is already taken.
120
+ if (webhooks.length > 0) {
121
+ runners.push(new WebhookIngressServer(this.webhook, webhooks, () => this.healthSnapshot()).run(ownedAbort.signal));
122
+ }
123
+ try {
124
+ await Promise.all(runners);
125
+ }
126
+ finally {
127
+ ownedAbort.abort();
128
+ queue.stop();
129
+ this.queue = undefined;
130
+ this.startedAt = undefined;
131
+ signal.removeEventListener("abort", stopOwned);
132
+ }
133
+ }
134
+ healthSnapshot() {
135
+ const adapters = [...this.adapterStates.values()];
136
+ return {
137
+ status: adapters.every(({ state }) => state === "running") ? "ready" : "degraded",
138
+ startedAt: this.startedAt,
139
+ adapters,
140
+ inbox: this.queue?.status() ?? { pending: 0, inFlight: 0, delayed: 0 },
141
+ };
142
+ }
143
+ async superviseAdapter(id, adapter, handler, signal) {
144
+ let attempts = 0;
145
+ while (!signal.aborted) {
146
+ this.setAdapterState({ id, channel: adapter.channel, state: "starting", attempts });
147
+ try {
148
+ this.setAdapterState({ id, channel: adapter.channel, state: "running", attempts });
149
+ await adapter.run(handler, signal);
150
+ if (signal.aborted)
151
+ break;
152
+ throw new Error("adapter exited unexpectedly");
153
+ }
154
+ catch (error) {
155
+ if (signal.aborted)
156
+ break;
157
+ attempts += 1;
158
+ const delay = Math.min(this.adapterRestart.maxMs, this.adapterRestart.baseMs * 2 ** Math.min(attempts - 1, 10));
159
+ this.setAdapterState({
160
+ id,
161
+ channel: adapter.channel,
162
+ state: "backoff",
163
+ attempts,
164
+ error: error instanceof Error ? error.message : String(error),
165
+ });
166
+ await abortableDelay(delay, signal);
167
+ }
168
+ }
169
+ this.setAdapterState({ id, channel: adapter.channel, state: "stopped", attempts });
170
+ }
171
+ setAdapterState(state) {
172
+ this.adapterStates.set(state.id, state);
173
+ this.onAdapterState?.({ ...state });
174
+ }
175
+ }
176
+ /** Deny-by-default allowlist middleware for multi-channel gateways. */
177
+ export function createAllowlistMiddleware(rules) {
178
+ const normalized = new Map(Object.entries(rules).map(([channel, rule]) => [
179
+ channel,
180
+ {
181
+ targets: new Set(rule.targetIds),
182
+ users: rule.userIds ? new Set(rule.userIds) : undefined,
183
+ },
184
+ ]));
185
+ return async ({ message }, next) => {
186
+ const rule = normalized.get(message.channel);
187
+ if (!rule?.targets.has(message.target))
188
+ return;
189
+ if (rule.users && rule.users.size > 0 && !rule.users.has(message.senderId))
190
+ return;
191
+ await next();
192
+ };
193
+ }
194
+ /** Fixed-window per-sender limiter; rejected messages never reach expensive middleware. */
195
+ export function createRateLimitMiddleware(maxMessages, windowMs = 60_000, onLimited) {
196
+ if (!Number.isSafeInteger(maxMessages) || maxMessages <= 0 || windowMs <= 0) {
197
+ throw new Error("invalid chat rate limit");
198
+ }
199
+ const buckets = new Map();
200
+ return async ({ message }, next) => {
201
+ const now = Date.now();
202
+ const key = `${message.channel}\0${message.senderId}`;
203
+ let bucket = buckets.get(key);
204
+ if (!bucket || now - bucket.startedAt >= windowMs) {
205
+ bucket = { startedAt: now, count: 0 };
206
+ buckets.set(key, bucket);
207
+ }
208
+ if (bucket.count >= maxMessages) {
209
+ onLimited?.(message);
210
+ return;
211
+ }
212
+ bucket.count += 1;
213
+ if (buckets.size > 10_000) {
214
+ for (const [candidate, value] of buckets) {
215
+ if (now - value.startedAt >= windowMs)
216
+ buckets.delete(candidate);
217
+ }
218
+ }
219
+ await next();
220
+ };
221
+ }
222
+ async function abortableDelay(ms, signal) {
223
+ if (signal.aborted)
224
+ return;
225
+ await Promise.race([
226
+ new Promise((resolve) => {
227
+ const timer = setTimeout(resolve, ms);
228
+ timer.unref?.();
229
+ }),
230
+ waitForAbort(signal),
231
+ ]);
232
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};