@happycastle/openclaw-channel-talk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,206 @@
1
+ # @happycastle/openclaw-channel-talk
2
+
3
+ > ⚠️ **Unofficial** — 이 플러그인은 Channel Corp 또는 OpenClaw 팀과 관련이 없는 커뮤니티 프로젝트입니다.
4
+
5
+ [Channel Talk (채널톡)](https://channel.io) Team Chat을 OpenClaw에 연동하는 채널 플러그인입니다.
6
+
7
+ ## ✨ Features
8
+
9
+ - 📨 **Team Chat 메시지 수신** — 웹훅을 통해 채널톡 팀챗 메시지를 실시간으로 수신
10
+ - 💬 **메시지 발송** — OpenClaw 에이전트가 채널톡 팀챗에 직접 응답
11
+ - 🤖 **커스텀 봇 이름** — `botName` 설정으로 봇 표시 이름 변경 가능
12
+ - 🔄 **자동 재시도** — API 오류(429, 5xx) 시 지수 백오프 재시도
13
+ - 📝 **Markdown 청킹** — 긴 메시지를 자동으로 분할하여 전송
14
+ - 🔒 **중복 메시지 필터링** — 동일 메시지 중복 처리 방지
15
+
16
+ ## 📋 Prerequisites
17
+
18
+ - [OpenClaw](https://github.com/nicepkg/openclaw)가 설치되어 실행 중이어야 합니다
19
+ - Channel Talk 계정 및 API 키 (Access Key + Access Secret)
20
+ - 웹훅 수신을 위한 공개 URL (Tailscale Funnel, ngrok, 리버스 프록시 등)
21
+
22
+ ## 🚀 설치 및 설정 가이드
23
+
24
+ ### 1단계: Channel Talk API 키 발급
25
+
26
+ 1. [채널 데스크](https://desk.channel.io)에 로그인
27
+ 2. **설정** → **보안 및 개발** → **API Key 관리**로 이동
28
+ 3. **새 API Key 생성** 클릭
29
+ 4. **Access Key**와 **Access Secret**을 안전하게 복사해 둡니다
30
+
31
+ ### 2단계: 플러그인 설치
32
+
33
+ **npm을 통한 설치 (권장):**
34
+
35
+ ```bash
36
+ openclaw plugins install @happycastle/openclaw-channel-talk
37
+ ```
38
+
39
+ **로컬 설치 (개발용):**
40
+
41
+ ```bash
42
+ git clone https://github.com/happycastle114/openclaw-channel-talk.git
43
+ cd openclaw-channel-talk
44
+ npm install
45
+ # OpenClaw 설정에서 로컬 경로를 지정합니다
46
+ ```
47
+
48
+ ### 3단계: OpenClaw 설정
49
+
50
+ OpenClaw 설정 파일(`config.yaml` 또는 `config.json`)에 다음을 추가합니다:
51
+
52
+ ```yaml
53
+ channels:
54
+ channel-talk:
55
+ # Channel Talk API 인증 정보 (필수)
56
+ accessKey: "your-access-key"
57
+ accessSecret: "your-access-secret"
58
+
59
+ # 봇 표시 이름 (선택, 기본값: API 기본 봇 이름)
60
+ botName: "MyBot"
61
+
62
+ # 팀챗 그룹 정책 (선택, 기본값: "open")
63
+ # "open" = 모든 팀챗 메시지 처리
64
+ # "closed" = 팀챗 메시지 처리 안 함
65
+ groupPolicy: "open"
66
+
67
+ # 웹훅 서버 설정 (선택)
68
+ webhook:
69
+ port: 3979 # 기본값: 3979
70
+ path: "/api/channel-talk" # 기본값: /api/channel-talk
71
+ ```
72
+
73
+ ### 4단계: 웹훅 엔드포인트 공개
74
+
75
+ 채널톡이 웹훅 이벤트를 보내려면 공개 URL이 필요합니다. 아래 방법 중 하나를 선택하세요:
76
+
77
+ **Tailscale Funnel (권장):**
78
+
79
+ ```bash
80
+ tailscale funnel 3979
81
+ # https://your-machine.tail12345.ts.net 형태의 URL이 생성됩니다
82
+ ```
83
+
84
+ **ngrok:**
85
+
86
+ ```bash
87
+ ngrok http 3979
88
+ # https://xxxx-xxxx.ngrok-free.app 형태의 URL이 생성됩니다
89
+ ```
90
+
91
+ **리버스 프록시 (Nginx, Caddy 등):**
92
+
93
+ 기존 도메인이 있다면 리버스 프록시로 `localhost:3979`를 포워딩합니다.
94
+
95
+ ### 5단계: Channel Talk 웹훅 등록
96
+
97
+ 채널톡 API를 사용하여 웹훅을 등록합니다:
98
+
99
+ ```bash
100
+ curl -X PUT "https://api.channel.io/open/v5/native/functions" \
101
+ -H "x-access-key: YOUR_ACCESS_KEY" \
102
+ -H "x-access-secret: YOUR_ACCESS_SECRET" \
103
+ -H "Content-Type: application/json" \
104
+ -d '{
105
+ "body": {
106
+ "nativeFunctions": [{
107
+ "name": "openclaw-webhook",
108
+ "uri": "https://YOUR_PUBLIC_URL/api/channel-talk",
109
+ "method": "POST",
110
+ "headers": {}
111
+ }]
112
+ }
113
+ }'
114
+ ```
115
+
116
+ > 💡 `YOUR_PUBLIC_URL`을 4단계에서 얻은 공개 URL로 교체하세요.
117
+
118
+ ### 6단계: 게이트웨이 시작
119
+
120
+ ```bash
121
+ openclaw gateway start
122
+ ```
123
+
124
+ 이제 채널톡 Team Chat에서 메시지를 보내면 OpenClaw 에이전트가 응답합니다! 🎉
125
+
126
+ ## ⚙️ Configuration Reference
127
+
128
+ | 키 | 타입 | 필수 | 기본값 | 설명 |
129
+ |---|---|---|---|---|
130
+ | `accessKey` | `string` | ✅ | — | Channel Talk API Access Key |
131
+ | `accessSecret` | `string` | ✅ | — | Channel Talk API Access Secret |
132
+ | `enabled` | `boolean` | ❌ | `true` | 플러그인 활성화/비활성화 |
133
+ | `botName` | `string` | ❌ | — | 봇 메시지 표시 이름 |
134
+ | `groupPolicy` | `"open" \| "closed"` | ❌ | `"open"` | 팀챗 그룹 메시지 처리 정책 |
135
+ | `webhook.port` | `number` | ❌ | `3979` | 웹훅 서버 포트 |
136
+ | `webhook.path` | `string` | ❌ | `"/api/channel-talk"` | 웹훅 엔드포인트 경로 |
137
+
138
+ ## 🏗️ Architecture
139
+
140
+ ```
141
+ ┌─────────────────┐ webhook POST ┌──────────────────┐
142
+ │ Channel Talk │ ───────────────────▶ │ OpenClaw │
143
+ │ (Team Chat) │ │ Gateway │
144
+ │ │ API response │ │
145
+ │ │ ◀─────────────────── │ ┌────────────┐ │
146
+ │ │ │ │ channel- │ │
147
+ │ │ │ │ talk plugin │ │
148
+ └─────────────────┘ │ └────────────┘ │
149
+ │ │ │
150
+ │ ▼ │
151
+ │ ┌────────────┐ │
152
+ │ │ Agent │ │
153
+ │ │ (LLM) │ │
154
+ │ └────────────┘ │
155
+ └──────────────────┘
156
+
157
+ 1. 채널톡 Team Chat에 메시지 작성
158
+ 2. 웹훅이 POST /api/channel-talk 으로 이벤트 전달
159
+ 3. 플러그인이 메시지를 파싱하여 에이전트에 전달
160
+ 4. 에이전트가 응답 생성
161
+ 5. Channel Talk API로 팀챗에 응답 전송
162
+ ```
163
+
164
+ ## 🔍 Verified API Behavior
165
+
166
+ 개발 과정에서 확인된 Channel Talk API 동작 특이사항:
167
+
168
+ - **웹훅 이벤트 형식**: 이벤트는 `event: "push"`로 수신됩니다. 상위 레벨에 `type` 필드가 없을 수 있습니다.
169
+ - **Group ID 위치**: `groupId`는 `entity.chatId`에서 가져옵니다. `refers.group.id`에는 없을 수 있습니다.
170
+ - **`actAsManager` 옵션**: Team Chat에서 사용 시 `422` 에러가 발생합니다. 이 옵션은 User Chat 전용입니다.
171
+ - **`botName` 파라미터**: 쿼리 파라미터로 전달하면 커스텀 봇 이름이 정상 작동합니다.
172
+ - **메시지 발신자 타입**: 봇이 보낸 메시지는 `personType: "bot"`으로 표시됩니다.
173
+
174
+ ## 🛠️ Troubleshooting
175
+
176
+ ### 웹훅이 수신되지 않는 경우
177
+
178
+ 1. 공개 URL이 올바르게 설정되었는지 확인합니다
179
+ 2. 게이트웨이가 실행 중인지 확인합니다: `openclaw gateway status`
180
+ 3. 포트가 방화벽에 의해 차단되지 않았는지 확인합니다
181
+ 4. 웹훅 등록 curl 명령을 다시 실행합니다
182
+
183
+ ### 인증 오류 (401/403)
184
+
185
+ - `accessKey`와 `accessSecret`이 올바른지 확인합니다
186
+ - API Key가 비활성화되지 않았는지 채널 데스크에서 확인합니다
187
+
188
+ ### 메시지 전송 실패 (422)
189
+
190
+ - `actAsManager` 옵션을 사용하지 마세요 — Team Chat에서는 지원되지 않습니다
191
+ - `groupId`가 유효한 팀챗 그룹 ID인지 확인합니다
192
+
193
+ ### 봇이 자기 메시지에 반응하는 경우
194
+
195
+ - 플러그인은 `personType: "bot"` 메시지를 자동으로 무시합니다
196
+ - 이 문제가 발생하면 로그를 확인해 주세요
197
+
198
+ ## 📄 License
199
+
200
+ MIT
201
+
202
+ ## ⚠️ Disclaimer
203
+
204
+ 이 프로젝트는 **비공식 커뮤니티 프로젝트**입니다.
205
+ [Channel Corp](https://channel.io) 또는 [OpenClaw](https://github.com/nicepkg/openclaw) 팀과 어떠한 제휴 관계도 없습니다.
206
+ Channel Talk은 Channel Corp의 상표입니다.
package/index.ts ADDED
@@ -0,0 +1,17 @@
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
+ import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
3
+ import { channelTalkPlugin } from "./src/channel.js";
4
+ import { setChannelTalkRuntime } from "./src/runtime.js";
5
+
6
+ const plugin = {
7
+ id: "channel-talk",
8
+ name: "Channel Talk",
9
+ description: "Channel Talk (채널톡) Team Chat channel plugin",
10
+ configSchema: emptyPluginConfigSchema(),
11
+ register(api: OpenClawPluginApi) {
12
+ setChannelTalkRuntime(api.runtime);
13
+ api.registerChannel({ plugin: channelTalkPlugin });
14
+ },
15
+ };
16
+
17
+ export default plugin;
@@ -0,0 +1,11 @@
1
+ {
2
+ "id": "channel-talk",
3
+ "channels": [
4
+ "channel-talk"
5
+ ],
6
+ "configSchema": {
7
+ "type": "object",
8
+ "additionalProperties": false,
9
+ "properties": {}
10
+ }
11
+ }
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@happycastle/openclaw-channel-talk",
3
+ "version": "0.1.0",
4
+ "description": "OpenClaw Channel Talk (채널톡) Team Chat channel plugin",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "happycastle114",
8
+ "homepage": "https://github.com/happycastle114/openclaw-channel-talk#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/happycastle114/openclaw-channel-talk.git"
12
+ },
13
+ "keywords": [
14
+ "openclaw",
15
+ "channel-talk",
16
+ "채널톡",
17
+ "chatbot",
18
+ "team-chat",
19
+ "plugin"
20
+ ],
21
+ "files": [
22
+ "index.ts",
23
+ "src/",
24
+ "openclaw.plugin.json",
25
+ "README.md"
26
+ ],
27
+ "dependencies": {
28
+ "express": "^4.19.0"
29
+ },
30
+ "peerDependencies": {
31
+ "openclaw": "*"
32
+ },
33
+ "openclaw": {
34
+ "extensions": [
35
+ "./index.ts"
36
+ ],
37
+ "channel": {
38
+ "id": "channel-talk",
39
+ "label": "Channel Talk",
40
+ "selectionLabel": "Channel Talk (채널톡)",
41
+ "docsPath": "channel-talk",
42
+ "docsLabel": "Channel Talk Setup",
43
+ "blurb": "채널톡 Team Chat integration",
44
+ "aliases": [
45
+ "channeltalk",
46
+ "채널톡"
47
+ ],
48
+ "order": 110
49
+ },
50
+ "install": {
51
+ "npmSpec": "@happycastle/openclaw-channel-talk",
52
+ "localPath": "extensions/channel-talk",
53
+ "defaultChoice": false
54
+ }
55
+ }
56
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Channel Talk API v5 HTTP Client
3
+ * Handles authentication, retries, and message sending
4
+ */
5
+
6
+ import type {
7
+ ChannelTalkConfig,
8
+ ChannelTalkCredentials,
9
+ SendMessageParams,
10
+ SendMessageResponse,
11
+ } from './types.js';
12
+
13
+ /**
14
+ * Sleep for specified milliseconds
15
+ * Used for exponential backoff retry delays
16
+ */
17
+ function sleep(ms: number): Promise<void> {
18
+ return new Promise((resolve) => setTimeout(resolve, ms));
19
+ }
20
+
21
+ /**
22
+ * Create an API client for Channel Talk
23
+ * Returns a client object with sendMessage method
24
+ *
25
+ * @param credentials - API credentials (accessKey, accessSecret)
26
+ * @param baseUrl - Optional base URL override (default: https://api.channel.io)
27
+ * @returns API client object with sendMessage method
28
+ */
29
+ export function createApiClient(
30
+ credentials: ChannelTalkCredentials,
31
+ baseUrl: string = 'https://api.channel.io'
32
+ ) {
33
+ return {
34
+ /**
35
+ * Send a message to a Channel Talk group
36
+ * Implements retry logic with exponential backoff for 429/5xx errors
37
+ *
38
+ * @param params - Message parameters (groupId, plainText, blocks, options, botName)
39
+ * @returns Promise resolving to SendMessageResponse with messageId and groupId
40
+ * @throws Error on authentication failure (401/403) or after max retries exhausted
41
+ */
42
+ async sendMessage(params: SendMessageParams): Promise<SendMessageResponse> {
43
+ const { groupId, plainText, blocks, options, botName } = params;
44
+
45
+ // Build request body
46
+ const body: Record<string, unknown> = {
47
+ plainText,
48
+ };
49
+
50
+ if (blocks && blocks.length > 0) {
51
+ body.blocks = blocks;
52
+ }
53
+
54
+ if (options && options.length > 0) {
55
+ body.options = options;
56
+ }
57
+
58
+ // Build URL with optional botName query parameter
59
+ const url = new URL(`/open/v5/groups/${groupId}/messages`, baseUrl);
60
+ if (botName) {
61
+ url.searchParams.set('botName', botName);
62
+ }
63
+
64
+ // Retry configuration: 2 retries with exponential backoff (1s, 3s)
65
+ const maxRetries = 2;
66
+ const retryDelays = [1000, 3000]; // milliseconds
67
+
68
+ let lastError: Error | null = null;
69
+
70
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
71
+ try {
72
+ const response = await fetch(url.toString(), {
73
+ method: 'POST',
74
+ headers: {
75
+ 'Content-Type': 'application/json',
76
+ 'x-access-key': credentials.accessKey,
77
+ 'x-access-secret': credentials.accessSecret,
78
+ },
79
+ body: JSON.stringify(body),
80
+ });
81
+
82
+ // Handle authentication errors (no retry)
83
+ if (response.status === 401 || response.status === 403) {
84
+ const errorText = await response.text();
85
+ throw new Error(
86
+ `Authentication failed (${response.status}): ${errorText}`
87
+ );
88
+ }
89
+
90
+ // Handle success
91
+ if (response.ok) {
92
+ const data = (await response.json()) as Record<string, unknown>;
93
+
94
+ // Extract messageId from response
95
+ // Response structure: { message: { id: "...", ... }, ... }
96
+ const messageId: string =
97
+ (String((data.message as Record<string, unknown>)?.id) || '') ||
98
+ (String(data.id) || '') ||
99
+ '';
100
+
101
+ return {
102
+ messageId,
103
+ groupId,
104
+ message: data.message as Record<string, unknown>,
105
+ };
106
+ }
107
+
108
+ // Handle retryable errors (429, 5xx)
109
+ if (response.status === 429 || response.status >= 500) {
110
+ const errorText = await response.text();
111
+ lastError = new Error(
112
+ `API error (${response.status}): ${errorText}`
113
+ );
114
+
115
+ // If this is the last attempt, throw
116
+ if (attempt === maxRetries) {
117
+ throw lastError;
118
+ }
119
+
120
+ // Wait before retry
121
+ const delayMs = retryDelays[attempt];
122
+ await sleep(delayMs);
123
+ continue;
124
+ }
125
+
126
+ // Handle other 4xx errors (no retry)
127
+ const errorText = await response.text();
128
+ throw new Error(
129
+ `API error (${response.status}): ${errorText}`
130
+ );
131
+ } catch (error) {
132
+ // Network errors or other exceptions
133
+ if (error instanceof Error) {
134
+ lastError = error;
135
+ } else {
136
+ lastError = new Error(String(error));
137
+ }
138
+
139
+ // If this is the last attempt, throw
140
+ if (attempt === maxRetries) {
141
+ throw lastError;
142
+ }
143
+
144
+ // Wait before retry
145
+ const delayMs = retryDelays[attempt];
146
+ await sleep(delayMs);
147
+ }
148
+ }
149
+
150
+ // Should not reach here, but throw last error if we do
151
+ throw lastError || new Error('Unknown error sending message');
152
+ },
153
+ };
154
+ }
155
+
156
+ /**
157
+ * Convenience function to send a message directly
158
+ * Creates a client and sends a message in one call
159
+ *
160
+ * @param credentials - API credentials
161
+ * @param params - Message parameters
162
+ * @param baseUrl - Optional base URL override
163
+ * @returns Promise resolving to SendMessageResponse
164
+ */
165
+ export async function sendMessage(
166
+ credentials: ChannelTalkCredentials,
167
+ params: SendMessageParams,
168
+ baseUrl?: string
169
+ ): Promise<SendMessageResponse> {
170
+ const client = createApiClient(credentials, baseUrl);
171
+ return client.sendMessage(params);
172
+ }
package/src/channel.ts ADDED
@@ -0,0 +1,181 @@
1
+ import type { ChannelPlugin, OpenClawConfig } from 'openclaw/plugin-sdk';
2
+ import { buildChannelConfigSchema, DEFAULT_ACCOUNT_ID } from 'openclaw/plugin-sdk';
3
+ import type { ChannelTalkCredentials } from './types.js';
4
+ import { ChannelTalkConfigSchema } from './config-schema.js';
5
+ import { channelTalkOutbound } from './send.js';
6
+ import { startChannelTalkWebhook } from './webhook.js';
7
+
8
+ export type ResolvedChannelTalkAccount = {
9
+ accountId: string;
10
+ credentials: ChannelTalkCredentials;
11
+ config: {
12
+ enabled?: boolean;
13
+ accessKey?: string;
14
+ accessSecret?: string;
15
+ botName?: string;
16
+ groupPolicy?: string;
17
+ webhook?: { port?: number; path?: string };
18
+ };
19
+ };
20
+
21
+ function readChannelConfig(cfg: OpenClawConfig): Record<string, unknown> | undefined {
22
+ return (cfg.channels as Record<string, Record<string, unknown>> | undefined)?.['channel-talk'];
23
+ }
24
+
25
+ function resolveCredentials(
26
+ raw: Record<string, unknown> | undefined,
27
+ ): ChannelTalkCredentials | null {
28
+ const accessKey = typeof raw?.accessKey === 'string' ? raw.accessKey : '';
29
+ const accessSecret = typeof raw?.accessSecret === 'string' ? raw.accessSecret : '';
30
+ if (!accessKey || !accessSecret) return null;
31
+ return { accessKey, accessSecret };
32
+ }
33
+
34
+ const meta = {
35
+ id: 'channel-talk',
36
+ label: 'Channel Talk',
37
+ selectionLabel: 'Channel Talk (채널톡)',
38
+ docsPath: '/channels/channel-talk',
39
+ docsLabel: 'Channel Talk Setup',
40
+ blurb: '채널톡 Team Chat integration',
41
+ aliases: ['channeltalk', '채널톡'],
42
+ order: 500,
43
+ } as const;
44
+
45
+ export const channelTalkPlugin: ChannelPlugin<ResolvedChannelTalkAccount> = {
46
+ id: 'channel-talk',
47
+
48
+ meta: { ...meta },
49
+
50
+ capabilities: {
51
+ chatTypes: ['channel'],
52
+ polls: false,
53
+ threads: false,
54
+ media: false,
55
+ },
56
+
57
+ reload: { configPrefixes: ['channels.channel-talk'] },
58
+
59
+ configSchema: buildChannelConfigSchema(ChannelTalkConfigSchema as any),
60
+
61
+ config: {
62
+ listAccountIds: () => [DEFAULT_ACCOUNT_ID],
63
+
64
+ resolveAccount: (cfg) => {
65
+ const raw = readChannelConfig(cfg);
66
+ const creds = resolveCredentials(raw);
67
+ return {
68
+ accountId: DEFAULT_ACCOUNT_ID,
69
+ credentials: creds ?? { accessKey: '', accessSecret: '' },
70
+ config: {
71
+ enabled: raw?.enabled !== false,
72
+ accessKey: typeof raw?.accessKey === 'string' ? raw.accessKey : undefined,
73
+ accessSecret: typeof raw?.accessSecret === 'string' ? raw.accessSecret : undefined,
74
+ botName: typeof raw?.botName === 'string' ? raw.botName : undefined,
75
+ groupPolicy: typeof raw?.groupPolicy === 'string' ? raw.groupPolicy : undefined,
76
+ webhook: raw?.webhook as { port?: number; path?: string } | undefined,
77
+ },
78
+ };
79
+ },
80
+
81
+ isConfigured: (account) =>
82
+ Boolean(account.credentials.accessKey && account.credentials.accessSecret),
83
+
84
+ resolveAllowFrom: () => undefined,
85
+ },
86
+
87
+ outbound: channelTalkOutbound,
88
+
89
+ gateway: {
90
+ startAccount: async (ctx) => {
91
+ const port =
92
+ (readChannelConfig(ctx.cfg)?.webhook as { port?: number } | undefined)?.port ?? 3979;
93
+ ctx.setStatus({ accountId: ctx.accountId, port } as any);
94
+ ctx.log?.info(`starting channel-talk webhook (port ${port})`);
95
+ return startChannelTalkWebhook({
96
+ cfg: ctx.cfg,
97
+ runtime: ctx.runtime,
98
+ abortSignal: ctx.abortSignal,
99
+ accountId: ctx.accountId,
100
+ setStatus: (next) => ctx.setStatus(next as any),
101
+ log: ctx.log,
102
+ });
103
+ },
104
+ },
105
+
106
+ setup: {
107
+ resolveAccountId: () => DEFAULT_ACCOUNT_ID,
108
+
109
+ applyAccountConfig: ({ cfg, input }) => ({
110
+ ...cfg,
111
+ channels: {
112
+ ...(cfg.channels as Record<string, unknown>),
113
+ 'channel-talk': {
114
+ ...readChannelConfig(cfg),
115
+ ...(input.token ? { accessKey: input.token } : {}),
116
+ ...(input.botToken ? { accessSecret: input.botToken } : {}),
117
+ enabled: true,
118
+ },
119
+ },
120
+ } as OpenClawConfig),
121
+ },
122
+
123
+ status: {
124
+ defaultRuntime: {
125
+ accountId: DEFAULT_ACCOUNT_ID,
126
+ running: false,
127
+ lastStartAt: null,
128
+ lastStopAt: null,
129
+ lastError: null,
130
+ },
131
+
132
+ buildChannelSummary: ({ snapshot }) => ({
133
+ configured: snapshot.configured ?? false,
134
+ running: snapshot.running ?? false,
135
+ lastStartAt: snapshot.lastStartAt ?? null,
136
+ lastStopAt: snapshot.lastStopAt ?? null,
137
+ lastError: snapshot.lastError ?? null,
138
+ port: snapshot.port ?? null,
139
+ }),
140
+
141
+ probeAccount: async ({ account }) => ({
142
+ configured: Boolean(
143
+ account.credentials.accessKey && account.credentials.accessSecret,
144
+ ),
145
+ enabled: account.config.enabled !== false,
146
+ }),
147
+
148
+ buildAccountSnapshot: ({ account, runtime }) => ({
149
+ accountId: account.accountId,
150
+ configured: Boolean(
151
+ account.credentials.accessKey && account.credentials.accessSecret,
152
+ ),
153
+ enabled: account.config.enabled !== false,
154
+ running: runtime?.running ?? false,
155
+ lastStartAt: runtime?.lastStartAt ?? null,
156
+ lastStopAt: runtime?.lastStopAt ?? null,
157
+ lastError: runtime?.lastError ?? null,
158
+ }),
159
+ },
160
+
161
+ security: {
162
+ collectWarnings: ({ cfg }) => {
163
+ const raw = readChannelConfig(cfg);
164
+ const defaultGroupPolicy = (
165
+ cfg.channels as Record<string, Record<string, unknown>> | undefined
166
+ )?.defaults?.groupPolicy as string | undefined;
167
+ const groupPolicy =
168
+ (typeof raw?.groupPolicy === 'string' ? raw.groupPolicy : undefined) ??
169
+ defaultGroupPolicy ??
170
+ 'open';
171
+
172
+ if (groupPolicy !== 'open') {
173
+ return [];
174
+ }
175
+ return [
176
+ `- Channel Talk: groupPolicy="open" processes all team chat messages. ` +
177
+ `Set channels.channel-talk.groupPolicy="closed" to disable team chat processing.`,
178
+ ];
179
+ },
180
+ },
181
+ };
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Channel Talk Plugin Configuration Schema
3
+ * Defines the structure and validation for Channel Talk plugin configuration
4
+ */
5
+
6
+ import { Type } from '@sinclair/typebox';
7
+ import type { Static } from '@sinclair/typebox';
8
+
9
+ /**
10
+ * Channel Talk configuration schema using TypeBox
11
+ * Defines required and optional configuration fields for the plugin
12
+ */
13
+ export const ChannelTalkConfigSchema = Type.Object(
14
+ {
15
+ /** Enable/disable the Channel Talk plugin */
16
+ enabled: Type.Optional(Type.Boolean({ default: true })),
17
+
18
+ /** Channel Talk API access key (required when enabled) */
19
+ accessKey: Type.String({
20
+ description: 'Channel Talk access key for API authentication',
21
+ }),
22
+
23
+ /** Channel Talk API access secret (required when enabled) */
24
+ accessSecret: Type.String({
25
+ description: 'Channel Talk access secret for API authentication',
26
+ }),
27
+
28
+ /** Webhook configuration */
29
+ webhook: Type.Optional(
30
+ Type.Object({
31
+ /** Port for webhook server (default: 3979) */
32
+ port: Type.Optional(Type.Number({ default: 3979 })),
33
+
34
+ /** Path for webhook endpoint (default: /api/channel-talk) */
35
+ path: Type.Optional(Type.String({ default: '/api/channel-talk' })),
36
+ })
37
+ ),
38
+
39
+ /** Bot display name for sent messages (optional) */
40
+ botName: Type.Optional(
41
+ Type.String({
42
+ description: 'Bot display name for sent messages',
43
+ })
44
+ ),
45
+
46
+ /** Group chat policy: 'open' = all groups allowed, 'closed' = none allowed */
47
+ groupPolicy: Type.Optional(
48
+ Type.Enum(['open', 'closed'], { default: 'open' })
49
+ ),
50
+ },
51
+ {
52
+ additionalProperties: false,
53
+ }
54
+ );
55
+
56
+ /**
57
+ * TypeScript type derived from the schema
58
+ * Use this for type-safe configuration handling
59
+ */
60
+ export type ChannelTalkConfig = Static<typeof ChannelTalkConfigSchema>;
package/src/runtime.ts ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Channel Talk Plugin Runtime Singleton
3
+ * Manages the plugin runtime instance for access throughout the plugin
4
+ */
5
+
6
+ import type { PluginRuntime } from 'openclaw/plugin-sdk';
7
+
8
+ /** Module-level runtime instance */
9
+ let runtime: PluginRuntime | undefined;
10
+
11
+ /**
12
+ * Set the Channel Talk plugin runtime
13
+ * Called during plugin initialization to store the runtime reference
14
+ *
15
+ * @param next - The PluginRuntime instance from OpenClaw
16
+ */
17
+ export function setChannelTalkRuntime(next: PluginRuntime): void {
18
+ runtime = next;
19
+ }
20
+
21
+ /**
22
+ * Get the Channel Talk plugin runtime
23
+ * Throws if runtime has not been initialized
24
+ *
25
+ * @returns The PluginRuntime instance
26
+ * @throws Error if runtime is not initialized
27
+ */
28
+ export function getChannelTalkRuntime(): PluginRuntime {
29
+ if (!runtime) {
30
+ throw new Error('Channel Talk runtime not initialized');
31
+ }
32
+ return runtime;
33
+ }
package/src/send.ts ADDED
@@ -0,0 +1,42 @@
1
+ import type { ChannelOutboundAdapter } from 'openclaw/plugin-sdk';
2
+ import { sendMessage } from './api-client.js';
3
+ import { getChannelTalkRuntime } from './runtime.js';
4
+
5
+ export const channelTalkOutbound: ChannelOutboundAdapter = {
6
+ deliveryMode: 'direct',
7
+
8
+ chunker: (text, limit) =>
9
+ getChannelTalkRuntime().channel.text.chunkMarkdownText(text, limit),
10
+
11
+ chunkerMode: 'markdown',
12
+ textChunkLimit: 4000,
13
+
14
+ sendText: async ({ cfg, to, text }) => {
15
+ const channelCfg = cfg.channels?.['channel-talk'] as
16
+ | { accessKey?: string; accessSecret?: string; botName?: string }
17
+ | undefined;
18
+
19
+ if (!channelCfg?.accessKey || !channelCfg?.accessSecret) {
20
+ throw new Error(
21
+ 'Channel Talk credentials not configured: missing accessKey or accessSecret in channels.channel-talk'
22
+ );
23
+ }
24
+
25
+ const credentials = {
26
+ accessKey: channelCfg.accessKey,
27
+ accessSecret: channelCfg.accessSecret,
28
+ };
29
+
30
+ const result = await sendMessage(credentials, {
31
+ groupId: to,
32
+ plainText: text,
33
+ botName: channelCfg.botName,
34
+ });
35
+
36
+ return {
37
+ channel: 'channel-talk',
38
+ messageId: result.messageId,
39
+ conversationId: to,
40
+ };
41
+ },
42
+ };
package/src/types.ts ADDED
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Channel Talk API v5 TypeScript Type Definitions
3
+ * Interfaces for webhook events, API requests/responses, and configuration
4
+ */
5
+
6
+ /**
7
+ * Channel Talk API credentials for authentication
8
+ * Uses x-access-key and x-access-secret headers
9
+ */
10
+ export interface ChannelTalkCredentials {
11
+ /** Access key for API authentication */
12
+ accessKey: string;
13
+ /** Access secret for API authentication */
14
+ accessSecret: string;
15
+ }
16
+
17
+ /**
18
+ * Channel Talk plugin configuration
19
+ * Contains credentials and optional base URL override
20
+ */
21
+ export interface ChannelTalkConfig {
22
+ /** API credentials */
23
+ credentials: ChannelTalkCredentials;
24
+ /** Base URL for API calls (default: https://api.channel.io) */
25
+ baseUrl?: string;
26
+ }
27
+
28
+ /**
29
+ * Text block in a message
30
+ * Supports HTML formatting: <b>, <i>, <link>, etc.
31
+ */
32
+ export interface TextBlock {
33
+ type: 'text';
34
+ /** HTML-formatted text content */
35
+ value: string;
36
+ }
37
+
38
+ /**
39
+ * Code block in a message
40
+ */
41
+ export interface CodeBlock {
42
+ type: 'code';
43
+ /** Code content */
44
+ value: string;
45
+ }
46
+
47
+ /**
48
+ * Bulleted list block in a message
49
+ */
50
+ export interface BulletsBlock {
51
+ type: 'bullets';
52
+ /** Array of text blocks for each bullet point */
53
+ blocks: TextBlock[];
54
+ }
55
+
56
+ /**
57
+ * Union type for all message block types
58
+ */
59
+ export type MessageBlock = TextBlock | CodeBlock | BulletsBlock;
60
+
61
+ /**
62
+ * Message options that control behavior
63
+ */
64
+ export type MessageOption =
65
+ | 'actAsManager'
66
+ | 'displayAsChannel'
67
+ | 'doNotPost'
68
+ | 'doNotSearch'
69
+ | 'doNotSendApp'
70
+ | 'doNotUpdateDesk'
71
+ | 'immutable'
72
+ | 'private'
73
+ | 'silent';
74
+
75
+ /**
76
+ * Parameters for sending a message to Channel Talk
77
+ */
78
+ export interface SendMessageParams {
79
+ /** Group ID (team chat identifier) */
80
+ groupId: string;
81
+ /** Plain text content of the message */
82
+ plainText: string;
83
+ /** Message blocks (optional, for rich formatting) */
84
+ blocks?: MessageBlock[];
85
+ /** Message options (optional) */
86
+ options?: MessageOption[];
87
+ /** Bot name to display as sender (optional) */
88
+ botName?: string;
89
+ }
90
+
91
+ /**
92
+ * Response from sending a message
93
+ */
94
+ export interface SendMessageResponse {
95
+ /** Message ID returned by API */
96
+ messageId: string;
97
+ /** Group ID where message was sent */
98
+ groupId: string;
99
+ /** Full message object from API response */
100
+ message?: Record<string, unknown>;
101
+ }
102
+
103
+ /**
104
+ * Manager entity in webhook event
105
+ */
106
+ export interface ChannelTalkManager {
107
+ /** Manager ID */
108
+ id: string;
109
+ /** Manager name */
110
+ name?: string;
111
+ }
112
+
113
+ /**
114
+ * Group (team chat) entity in webhook event
115
+ */
116
+ export interface ChannelTalkGroup {
117
+ /** Group ID */
118
+ id: string;
119
+ /** Group name */
120
+ name?: string;
121
+ }
122
+
123
+ /**
124
+ * References to related entities in webhook event
125
+ */
126
+ export interface ChannelTalkRefers {
127
+ /** Manager who sent/received the message */
128
+ manager?: ChannelTalkManager;
129
+ /** Group (team chat) where message occurred */
130
+ group?: ChannelTalkGroup;
131
+ }
132
+
133
+ /**
134
+ * Entity data in webhook event
135
+ * Contains the actual message content and metadata
136
+ */
137
+ export interface ChannelTalkEntity {
138
+ /** Message ID */
139
+ id: string;
140
+ /** Chat type: 'group' for team chat, 'user' for direct, 'customer' for customer chat */
141
+ chatType: 'group' | 'user' | 'customer';
142
+ /** Person type: 'manager' for staff, 'bot' for automated messages, 'customer' for customers */
143
+ personType: 'manager' | 'bot' | 'customer';
144
+ /** Message blocks (rich formatting) */
145
+ blocks?: MessageBlock[];
146
+ /** Plain text representation of message */
147
+ plainText?: string;
148
+ /** Chat ID */
149
+ chatId?: string;
150
+ /** Person ID (sender) */
151
+ personId?: string;
152
+ /** Created timestamp */
153
+ createdAt?: number;
154
+ }
155
+
156
+ /**
157
+ * Webhook event from Channel Talk
158
+ * Sent when messages are created or other events occur
159
+ */
160
+ export interface ChannelTalkWebhookEvent {
161
+ /** Event action (e.g., 'push') */
162
+ event: string;
163
+ /** Event type identifier (e.g., 'message.created.teamChat') — may be absent for push events */
164
+ type?: string;
165
+ /** Entity data (message content, metadata) */
166
+ entity: ChannelTalkEntity;
167
+ /** References to related entities (manager, group) */
168
+ refers: ChannelTalkRefers;
169
+ }
package/src/webhook.ts ADDED
@@ -0,0 +1,319 @@
1
+ import type { Server } from 'node:http';
2
+ import type { OpenClawConfig } from 'openclaw/plugin-sdk';
3
+ import type { ChannelTalkWebhookEvent } from './types.js';
4
+ import { createApiClient } from './api-client.js';
5
+ import { getChannelTalkRuntime } from './runtime.js';
6
+
7
+ const DEFAULT_ACCOUNT_ID = 'default';
8
+ const DEDUP_TTL_MS = 60_000;
9
+ const DEDUP_CLEANUP_INTERVAL_MS = 30_000;
10
+
11
+ export type StartChannelTalkWebhookContext = {
12
+ cfg: OpenClawConfig;
13
+ runtime: { log?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void };
14
+ abortSignal: AbortSignal;
15
+ accountId?: string;
16
+ setStatus?: (next: Record<string, unknown>) => void;
17
+ log?: {
18
+ info: (msg: string, meta?: Record<string, unknown>) => void;
19
+ warn: (msg: string, meta?: Record<string, unknown>) => void;
20
+ error: (msg: string, meta?: Record<string, unknown>) => void;
21
+ debug?: (msg: string, meta?: Record<string, unknown>) => void;
22
+ };
23
+ };
24
+
25
+ export type StartChannelTalkWebhookResult = {
26
+ server: Server;
27
+ shutdown: () => Promise<void>;
28
+ };
29
+
30
+ export async function startChannelTalkWebhook(
31
+ ctx: StartChannelTalkWebhookContext,
32
+ ): Promise<StartChannelTalkWebhookResult> {
33
+ const core = getChannelTalkRuntime();
34
+ const log = ctx.log ?? {
35
+ info: (msg: string) => ctx.runtime.log?.(`[channel-talk] ${msg}`),
36
+ warn: (msg: string) => ctx.runtime.log?.(`[channel-talk] WARN: ${msg}`),
37
+ error: (msg: string) => ctx.runtime.error?.(`[channel-talk] ${msg}`),
38
+ debug: (msg: string) => ctx.runtime.log?.(`[channel-talk] ${msg}`),
39
+ };
40
+
41
+ const channelTalkCfg = (ctx.cfg.channels as Record<string, Record<string, unknown>> | undefined)?.['channel-talk'];
42
+ if (!channelTalkCfg) {
43
+ log.error('channel-talk config not found');
44
+ throw new Error('channel-talk config not found in cfg.channels');
45
+ }
46
+
47
+ const accessKey = channelTalkCfg.accessKey as string | undefined;
48
+ const accessSecret = channelTalkCfg.accessSecret as string | undefined;
49
+ if (!accessKey || !accessSecret) {
50
+ log.error('channel-talk credentials not configured');
51
+ throw new Error('channel-talk credentials (accessKey, accessSecret) not configured');
52
+ }
53
+
54
+ const webhookCfg = channelTalkCfg.webhook as { port?: number; path?: string } | undefined;
55
+ const port = webhookCfg?.port ?? 3979;
56
+ const webhookPath = webhookCfg?.path ?? '/api/channel-talk';
57
+ const botName = channelTalkCfg.botName as string | undefined;
58
+ const accountId = ctx.accountId ?? DEFAULT_ACCOUNT_ID;
59
+
60
+ const apiClient = createApiClient({ accessKey, accessSecret }, channelTalkCfg.baseUrl as string | undefined);
61
+
62
+ const dedupCache = new Map<string, number>();
63
+
64
+ const cleanupDedup = () => {
65
+ const now = Date.now();
66
+ for (const [key, ts] of dedupCache) {
67
+ if (now - ts > DEDUP_TTL_MS) {
68
+ dedupCache.delete(key);
69
+ }
70
+ }
71
+ };
72
+
73
+ const dedupTimer = setInterval(cleanupDedup, DEDUP_CLEANUP_INTERVAL_MS);
74
+ (dedupTimer as unknown as { unref?: () => void }).unref?.();
75
+
76
+ const isDuplicate = (messageId: string): boolean => {
77
+ if (dedupCache.has(messageId)) {
78
+ return true;
79
+ }
80
+ dedupCache.set(messageId, Date.now());
81
+ return false;
82
+ };
83
+
84
+ const express = (await import('express')).default;
85
+ const app = express();
86
+ app.use(express.json());
87
+
88
+ app.post(webhookPath, (req: { body: unknown }, res: { status: (code: number) => { json: (data: unknown) => void } }) => {
89
+ res.status(200).json({ ok: true });
90
+
91
+ const body = req.body as ChannelTalkWebhookEvent | undefined;
92
+ if (!body) {
93
+ log.debug?.('empty webhook body');
94
+ return;
95
+ }
96
+
97
+ void handleWebhookEvent(body).catch((err: unknown) => {
98
+ log.error('webhook handler error', { error: String(err) });
99
+ });
100
+ });
101
+
102
+ async function handleWebhookEvent(event: ChannelTalkWebhookEvent): Promise<void> {
103
+ const isTeamChatMessage =
104
+ event.event === 'push' ||
105
+ event.type === 'message.created.teamChat';
106
+
107
+ if (!isTeamChatMessage) {
108
+ log.debug?.('skipping non-message event', {
109
+ event: event.event,
110
+ type: event.type,
111
+ });
112
+ return;
113
+ }
114
+
115
+ const entity = event.entity;
116
+ if (!entity) {
117
+ log.debug?.('skipping event without entity');
118
+ return;
119
+ }
120
+
121
+ if (entity.chatType !== 'group') {
122
+ log.debug?.('skipping non-group message', { chatType: entity.chatType });
123
+ return;
124
+ }
125
+
126
+ if (entity.personType === 'bot') {
127
+ log.debug?.('skipping bot message');
128
+ return;
129
+ }
130
+
131
+ const messageId = entity.id;
132
+ if (!messageId) {
133
+ log.debug?.('skipping message without id');
134
+ return;
135
+ }
136
+ if (isDuplicate(messageId)) {
137
+ log.debug?.('skipping duplicate message', { messageId });
138
+ return;
139
+ }
140
+
141
+ const plainText = entity.plainText?.trim() ?? '';
142
+ if (!plainText) {
143
+ log.debug?.('skipping empty message');
144
+ return;
145
+ }
146
+
147
+ const refers = event.refers;
148
+ const groupId = entity.chatId ?? refers?.group?.id;
149
+ if (!groupId) {
150
+ log.debug?.('skipping message without group id');
151
+ return;
152
+ }
153
+
154
+ const managerId = refers?.manager?.id ?? entity.personId ?? 'unknown';
155
+ const managerName = refers?.manager?.name ?? managerId;
156
+ const timestamp = entity.createdAt ?? Date.now();
157
+
158
+ log.info('received team chat message', {
159
+ messageId,
160
+ groupId,
161
+ from: managerName,
162
+ preview: plainText.slice(0, 80),
163
+ });
164
+
165
+ const route = core.channel.routing.resolveAgentRoute({
166
+ cfg: ctx.cfg,
167
+ channel: 'channel-talk',
168
+ peer: {
169
+ kind: 'group' as const,
170
+ id: groupId,
171
+ },
172
+ });
173
+
174
+ const storePath = core.channel.session.resolveStorePath(ctx.cfg.session?.store, {
175
+ agentId: route.agentId,
176
+ });
177
+
178
+ const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(ctx.cfg);
179
+ const previousTimestamp = core.channel.session.readSessionUpdatedAt({
180
+ storePath,
181
+ sessionKey: route.sessionKey,
182
+ });
183
+
184
+ const formattedBody = core.channel.reply.formatAgentEnvelope({
185
+ channel: 'Channel Talk',
186
+ from: managerName,
187
+ timestamp: new Date(timestamp),
188
+ previousTimestamp,
189
+ envelope: envelopeOptions,
190
+ body: plainText,
191
+ });
192
+
193
+ const preview = plainText.replace(/\s+/g, ' ').slice(0, 160);
194
+ core.system.enqueueSystemEvent(
195
+ `Channel Talk message from ${managerName}: ${preview}`,
196
+ {
197
+ sessionKey: route.sessionKey,
198
+ contextKey: `channel-talk:message:${groupId}:${messageId}`,
199
+ },
200
+ );
201
+
202
+ const ctxPayload = core.channel.reply.finalizeInboundContext({
203
+ Body: formattedBody,
204
+ RawBody: plainText,
205
+ CommandBody: plainText,
206
+ From: `channel-talk:${managerId}`,
207
+ To: `group:${groupId}`,
208
+ SessionKey: route.sessionKey,
209
+ AccountId: route.accountId ?? accountId,
210
+ ChatType: 'channel' as const,
211
+ ConversationLabel: managerName,
212
+ SenderName: managerName,
213
+ SenderId: managerId,
214
+ Provider: 'channel-talk' as const,
215
+ Surface: 'channel-talk' as const,
216
+ MessageSid: messageId,
217
+ Timestamp: timestamp,
218
+ WasMentioned: false,
219
+ CommandAuthorized: false,
220
+ OriginatingChannel: 'channel-talk' as const,
221
+ OriginatingTo: `group:${groupId}`,
222
+ });
223
+
224
+ await core.channel.session.recordInboundSession({
225
+ storePath,
226
+ sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
227
+ ctx: ctxPayload,
228
+ onRecordError: (err: unknown) => {
229
+ log.debug?.(`failed updating session meta: ${String(err)}`);
230
+ },
231
+ });
232
+
233
+ const textLimit = core.channel.text.resolveTextChunkLimit(ctx.cfg, 'channel-talk');
234
+
235
+ const { dispatcher, replyOptions, markDispatchIdle } =
236
+ core.channel.reply.createReplyDispatcherWithTyping({
237
+ deliver: async (payload: { text?: string }) => {
238
+ const replyText = payload.text;
239
+ if (!replyText) {
240
+ return;
241
+ }
242
+
243
+ const chunks = core.channel.text.chunkMarkdownText(replyText, textLimit);
244
+ for (const chunk of chunks) {
245
+ await apiClient.sendMessage({
246
+ groupId,
247
+ plainText: chunk,
248
+ botName,
249
+ });
250
+ }
251
+ },
252
+ onError: (err: unknown) => {
253
+ log.error('reply dispatch error', { error: String(err) });
254
+ },
255
+ });
256
+
257
+ log.info('dispatching to agent', { sessionKey: route.sessionKey });
258
+
259
+ try {
260
+ const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
261
+ ctx: ctxPayload,
262
+ cfg: ctx.cfg,
263
+ dispatcher,
264
+ replyOptions,
265
+ });
266
+
267
+ markDispatchIdle();
268
+ log.info('dispatch complete', { queuedFinal, counts });
269
+ } catch (err) {
270
+ log.error('dispatch failed', { error: String(err) });
271
+ ctx.runtime.error?.(`channel-talk dispatch failed: ${String(err)}`);
272
+ }
273
+ }
274
+
275
+ const httpServer = app.listen(port, () => {
276
+ log.info(`channel-talk webhook started on port ${port}, path ${webhookPath}`);
277
+ });
278
+
279
+ httpServer.on('error', (err: Error) => {
280
+ log.error('server error', { error: String(err) });
281
+ });
282
+
283
+ ctx.setStatus?.({
284
+ accountId,
285
+ running: true,
286
+ connected: true,
287
+ lastStartAt: Date.now(),
288
+ port,
289
+ webhookPath,
290
+ });
291
+
292
+ const shutdown = async (): Promise<void> => {
293
+ log.info('shutting down channel-talk webhook');
294
+ clearInterval(dedupTimer);
295
+ dedupCache.clear();
296
+ return new Promise<void>((resolve) => {
297
+ httpServer.close((err?: Error) => {
298
+ if (err) {
299
+ log.debug?.(`server close error: ${String(err)}`);
300
+ }
301
+ ctx.setStatus?.({
302
+ accountId,
303
+ running: false,
304
+ connected: false,
305
+ lastStopAt: Date.now(),
306
+ });
307
+ resolve();
308
+ });
309
+ });
310
+ };
311
+
312
+ if (ctx.abortSignal) {
313
+ ctx.abortSignal.addEventListener('abort', () => {
314
+ void shutdown();
315
+ });
316
+ }
317
+
318
+ return { server: httpServer, shutdown };
319
+ }