@ascegu/teamily 1.0.18 → 1.0.19
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 +9 -20
- package/package.json +1 -1
- package/src/channel.ts +453 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Teamily Channel Plugin for OpenClaw
|
|
2
2
|
|
|
3
|
-
Integrates [Teamily](https://teamily.ai/)
|
|
3
|
+
Integrates [Teamily](https://teamily.ai/) with OpenClaw as a self-hosted team messaging channel.
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
@@ -28,9 +28,9 @@ openclaw channel configure teamily
|
|
|
28
28
|
|
|
29
29
|
| Field | Description | Default |
|
|
30
30
|
|---------------|--------------------------|---------------------------|
|
|
31
|
-
| `platformUrl` | Teamily platform URL | `
|
|
32
|
-
| `apiURL` | Teamily REST API URL | `
|
|
33
|
-
| `wsURL` | Teamily WebSocket URL | `
|
|
31
|
+
| `platformUrl` | Teamily platform URL | `https://imserver-test.teamily.ai/im_api` |
|
|
32
|
+
| `apiURL` | Teamily REST API URL | `https://imserver-test.teamily.ai/im_api` |
|
|
33
|
+
| `wsURL` | Teamily WebSocket URL | `wss://imserver-test.teamily.ai/msg_gateway` |
|
|
34
34
|
|
|
35
35
|
### Bot Account Settings
|
|
36
36
|
|
|
@@ -57,9 +57,9 @@ channels:
|
|
|
57
57
|
teamily:
|
|
58
58
|
enabled: true
|
|
59
59
|
server:
|
|
60
|
-
platformUrl:
|
|
61
|
-
apiURL:
|
|
62
|
-
wsURL:
|
|
60
|
+
platformUrl: https://imserver-test.teamily.ai/im_api
|
|
61
|
+
apiURL: https://imserver-test.teamily.ai/im_api
|
|
62
|
+
wsURL: wss://imserver-test.teamily.ai/msg_gateway
|
|
63
63
|
accounts:
|
|
64
64
|
default:
|
|
65
65
|
userID: "bot-user-id"
|
|
@@ -100,8 +100,8 @@ Supported media types are auto-detected by file extension:
|
|
|
100
100
|
|
|
101
101
|
## Group Chat Behavior
|
|
102
102
|
|
|
103
|
-
-
|
|
104
|
-
-
|
|
103
|
+
- All group messages are received and dispatched to the agent for context accumulation.
|
|
104
|
+
- The bot only **replies** when it is **@-mentioned** in the group (`@BotName`).
|
|
105
105
|
- In direct messages, the bot always replies.
|
|
106
106
|
- Both regular groups (`sessionType=3`) and super groups (`sessionType=2`) are supported.
|
|
107
107
|
|
|
@@ -114,7 +114,6 @@ Supported media types are auto-detected by file extension:
|
|
|
114
114
|
| Text messages | Yes |
|
|
115
115
|
| Media (image/video/audio/file) | Yes |
|
|
116
116
|
| @-mention gating (groups) | Yes |
|
|
117
|
-
| Group history context (50 msg buffer) | Yes |
|
|
118
117
|
| WebSocket real-time monitoring | Yes |
|
|
119
118
|
| Automatic reconnection | Yes |
|
|
120
119
|
| Connection health probes | Yes |
|
|
@@ -122,12 +121,6 @@ Supported media types are auto-detected by file extension:
|
|
|
122
121
|
| Threads | No |
|
|
123
122
|
| Polls | No |
|
|
124
123
|
|
|
125
|
-
## Setting Up Teamily Server
|
|
126
|
-
|
|
127
|
-
1. **Deploy OpenIM server** -- follow the [OpenIM Quick Start](https://docs.openim.io/guides/gettingStarted/introduction) guide.
|
|
128
|
-
2. **Create a bot user** -- use the Teamily management API to create a bot account and obtain its `userID` and `token`.
|
|
129
|
-
3. **Configure OpenClaw** -- run `openclaw channel configure teamily` and provide the server URLs and bot credentials.
|
|
130
|
-
|
|
131
124
|
## Architecture
|
|
132
125
|
|
|
133
126
|
```
|
|
@@ -144,10 +137,6 @@ src/
|
|
|
144
137
|
send.ts REST API message/media send (fallback path)
|
|
145
138
|
```
|
|
146
139
|
|
|
147
|
-
## Compatibility
|
|
148
|
-
|
|
149
|
-
Designed for OpenIM API v2/v3. Requires `@openim/client-sdk` ^3.8.3.
|
|
150
|
-
|
|
151
140
|
## License
|
|
152
141
|
|
|
153
142
|
MIT
|
package/package.json
CHANGED
package/src/channel.ts
ADDED
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
import {
|
|
2
|
+
applyAccountNameToChannelSection,
|
|
3
|
+
buildChannelConfigSchema,
|
|
4
|
+
buildPendingHistoryContextFromMap,
|
|
5
|
+
clearHistoryEntriesIfEnabled,
|
|
6
|
+
DEFAULT_ACCOUNT_ID,
|
|
7
|
+
DEFAULT_GROUP_HISTORY_LIMIT,
|
|
8
|
+
deleteAccountFromConfigSection,
|
|
9
|
+
normalizeAccountId,
|
|
10
|
+
PAIRING_APPROVED_MESSAGE,
|
|
11
|
+
recordPendingHistoryEntryIfEnabled,
|
|
12
|
+
setAccountEnabledInConfigSection,
|
|
13
|
+
type ChannelPlugin,
|
|
14
|
+
type ChannelOutboundContext,
|
|
15
|
+
type ChannelStatusIssue,
|
|
16
|
+
type HistoryEntry,
|
|
17
|
+
} from "openclaw/plugin-sdk";
|
|
18
|
+
import {
|
|
19
|
+
buildAccountScopedDmSecurityPolicy,
|
|
20
|
+
createScopedAccountConfigAccessors,
|
|
21
|
+
} from "openclaw/plugin-sdk/compat";
|
|
22
|
+
import {
|
|
23
|
+
listTeamilyAccountIds,
|
|
24
|
+
resolveDefaultTeamilyAccountId,
|
|
25
|
+
resolveTeamilyAccount,
|
|
26
|
+
} from "./accounts.js";
|
|
27
|
+
import { TeamilyConfigSchema } from "./config-schema.js";
|
|
28
|
+
import type { CoreConfig } from "./config-schema.js";
|
|
29
|
+
import { getTeamilyMonitor, startTeamilyMonitoring, stopTeamilyMonitoring } from "./monitor.js";
|
|
30
|
+
import { normalizeTeamilyTarget, normalizeTeamilyAllowEntry } from "./normalize.js";
|
|
31
|
+
import { probeTeamily } from "./probe.js";
|
|
32
|
+
import { getTeamilyRuntime } from "./runtime.js";
|
|
33
|
+
import type { ResolvedTeamilyAccount } from "./types.js";
|
|
34
|
+
import { isGroupSession } from "./types.js";
|
|
35
|
+
|
|
36
|
+
const meta = {
|
|
37
|
+
id: "teamily",
|
|
38
|
+
label: "Teamily",
|
|
39
|
+
selectionLabel: "Teamily (self-hosted)",
|
|
40
|
+
docsPath: "/channels/teamily",
|
|
41
|
+
docsLabel: "teamily",
|
|
42
|
+
blurb: "Team instant messaging server",
|
|
43
|
+
order: 75,
|
|
44
|
+
quickstartAllowFrom: true,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const teamilyConfigAccessors = createScopedAccountConfigAccessors({
|
|
48
|
+
resolveAccount: ({ cfg, accountId }) => resolveTeamilyAccount(cfg as CoreConfig, accountId),
|
|
49
|
+
resolveAllowFrom: (account) => account.dm?.allowFrom,
|
|
50
|
+
formatAllowFrom: (allowFrom) => allowFrom.map((id) => String(id)),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
export const teamilyPlugin: ChannelPlugin<ResolvedTeamilyAccount> = {
|
|
54
|
+
id: "teamily",
|
|
55
|
+
meta,
|
|
56
|
+
capabilities: {
|
|
57
|
+
chatTypes: ["direct", "group"],
|
|
58
|
+
media: true,
|
|
59
|
+
reactions: false,
|
|
60
|
+
threads: false,
|
|
61
|
+
polls: false,
|
|
62
|
+
},
|
|
63
|
+
reload: { configPrefixes: ["channels.teamily"] },
|
|
64
|
+
setup: {
|
|
65
|
+
resolveAccountId: ({ accountId, input }) => {
|
|
66
|
+
if (accountId) return accountId;
|
|
67
|
+
if (input?.name) return normalizeAccountId(String(input.name));
|
|
68
|
+
return DEFAULT_ACCOUNT_ID;
|
|
69
|
+
},
|
|
70
|
+
applyAccountName: ({ cfg, accountId, name }) =>
|
|
71
|
+
applyAccountNameToChannelSection({
|
|
72
|
+
cfg,
|
|
73
|
+
channelKey: "teamily",
|
|
74
|
+
accountId,
|
|
75
|
+
name,
|
|
76
|
+
}),
|
|
77
|
+
applyAccountConfig: ({ cfg, accountId, input }) =>
|
|
78
|
+
applyTeamilyAccountConfig({
|
|
79
|
+
cfg: cfg as CoreConfig,
|
|
80
|
+
accountId,
|
|
81
|
+
input: input as Record<string, unknown>,
|
|
82
|
+
}),
|
|
83
|
+
},
|
|
84
|
+
pairing: {
|
|
85
|
+
idLabel: "teamilyUserId",
|
|
86
|
+
normalizeAllowEntry: (entry) => {
|
|
87
|
+
try {
|
|
88
|
+
return normalizeTeamilyAllowEntry(entry);
|
|
89
|
+
} catch {
|
|
90
|
+
return entry;
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
notifyApproval: async ({ id, cfg }) => {
|
|
94
|
+
try {
|
|
95
|
+
const accountId = resolveDefaultTeamilyAccountId(cfg as CoreConfig);
|
|
96
|
+
const target = normalizeTeamilyTarget(id);
|
|
97
|
+
const monitor = getTeamilyMonitor(accountId);
|
|
98
|
+
if (monitor) {
|
|
99
|
+
await monitor.sendText(target, PAIRING_APPROVED_MESSAGE);
|
|
100
|
+
}
|
|
101
|
+
// If monitor isn't running, skip silently — pairing was still approved
|
|
102
|
+
} catch {
|
|
103
|
+
// Silently fail on notification
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
configSchema: buildChannelConfigSchema(TeamilyConfigSchema),
|
|
108
|
+
config: {
|
|
109
|
+
listAccountIds: (cfg) => listTeamilyAccountIds(cfg as CoreConfig),
|
|
110
|
+
resolveAccount: (cfg, accountId) => resolveTeamilyAccount(cfg as CoreConfig, accountId),
|
|
111
|
+
defaultAccountId: (cfg) => resolveDefaultTeamilyAccountId(cfg as CoreConfig),
|
|
112
|
+
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
|
113
|
+
setAccountEnabledInConfigSection({
|
|
114
|
+
cfg: cfg as CoreConfig,
|
|
115
|
+
sectionKey: "teamily",
|
|
116
|
+
accountId,
|
|
117
|
+
enabled,
|
|
118
|
+
allowTopLevel: true,
|
|
119
|
+
}),
|
|
120
|
+
deleteAccount: ({ cfg, accountId }) =>
|
|
121
|
+
deleteAccountFromConfigSection({
|
|
122
|
+
cfg: cfg as CoreConfig,
|
|
123
|
+
sectionKey: "teamily",
|
|
124
|
+
accountId,
|
|
125
|
+
clearBaseFields: ["name", "userID", "token", "nickname", "faceURL"],
|
|
126
|
+
}),
|
|
127
|
+
isConfigured: (account) => !!account.token,
|
|
128
|
+
describeAccount: (account) => ({
|
|
129
|
+
accountId: account.accountId,
|
|
130
|
+
name: account.nickname || account.userID,
|
|
131
|
+
enabled: account.enabled,
|
|
132
|
+
configured: !!account.token,
|
|
133
|
+
}),
|
|
134
|
+
...teamilyConfigAccessors,
|
|
135
|
+
},
|
|
136
|
+
security: {
|
|
137
|
+
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
|
138
|
+
return buildAccountScopedDmSecurityPolicy({
|
|
139
|
+
cfg: cfg as CoreConfig,
|
|
140
|
+
channelKey: "teamily",
|
|
141
|
+
accountId,
|
|
142
|
+
fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID,
|
|
143
|
+
policy: account.dm?.policy,
|
|
144
|
+
allowFrom: account.dm?.allowFrom ?? [],
|
|
145
|
+
allowFromPathSuffix: "dm.",
|
|
146
|
+
normalizeEntry: (raw) => normalizeTeamilyAllowEntry(raw),
|
|
147
|
+
});
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
outbound: {
|
|
151
|
+
deliveryMode: "gateway",
|
|
152
|
+
resolveTarget: ({ to }) => {
|
|
153
|
+
if (!to?.trim()) {
|
|
154
|
+
return { ok: false, error: new Error("Teamily requires --to <userId|group:groupId>") };
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
const target = normalizeTeamilyTarget(to);
|
|
158
|
+
// Preserve the full target format so sendText/sendMedia can distinguish user vs group
|
|
159
|
+
const resolved = target.type === "group" ? `group:${target.id}` : target.id;
|
|
160
|
+
return { ok: true, to: resolved };
|
|
161
|
+
} catch (err) {
|
|
162
|
+
return { ok: false, error: err instanceof Error ? err : new Error(String(err)) };
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
sendText: async (ctx: ChannelOutboundContext) => {
|
|
166
|
+
const { to, text, accountId } = ctx;
|
|
167
|
+
const monitor = requireMonitor(accountId);
|
|
168
|
+
const target = normalizeTeamilyTarget(to);
|
|
169
|
+
const messageId = await monitor.sendText(target, text);
|
|
170
|
+
return { channel: "teamily" as const, messageId };
|
|
171
|
+
},
|
|
172
|
+
sendMedia: async (ctx: ChannelOutboundContext) => {
|
|
173
|
+
const { to, accountId } = ctx;
|
|
174
|
+
const mediaUrl = ctx.mediaUrl;
|
|
175
|
+
if (!mediaUrl) {
|
|
176
|
+
throw new Error("Media URL is required");
|
|
177
|
+
}
|
|
178
|
+
const monitor = requireMonitor(accountId);
|
|
179
|
+
const target = normalizeTeamilyTarget(to);
|
|
180
|
+
|
|
181
|
+
let messageId: string;
|
|
182
|
+
const urlLower = mediaUrl.toLowerCase();
|
|
183
|
+
if (urlLower.endsWith(".mp4") || urlLower.endsWith(".mov") || urlLower.endsWith(".webm")) {
|
|
184
|
+
messageId = await monitor.sendVideo(target, mediaUrl);
|
|
185
|
+
} else if (urlLower.endsWith(".mp3") || urlLower.endsWith(".m4a") || urlLower.endsWith(".wav")) {
|
|
186
|
+
messageId = await monitor.sendAudio(target, mediaUrl);
|
|
187
|
+
} else if (urlLower.endsWith(".pdf") || urlLower.endsWith(".doc") || urlLower.endsWith(".docx") || urlLower.endsWith(".zip")) {
|
|
188
|
+
messageId = await monitor.sendFile(target, mediaUrl);
|
|
189
|
+
} else {
|
|
190
|
+
messageId = await monitor.sendImage(target, mediaUrl);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return { channel: "teamily" as const, messageId };
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
status: {
|
|
197
|
+
probeAccount: async ({ account }) => {
|
|
198
|
+
const result = await probeTeamily(account);
|
|
199
|
+
if (!result.connected) {
|
|
200
|
+
return {
|
|
201
|
+
ok: false,
|
|
202
|
+
error: result.error || "Failed to connect to Teamily server",
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
return { ok: true };
|
|
206
|
+
},
|
|
207
|
+
buildAccountSnapshot: ({ account, runtime, probe }) => ({
|
|
208
|
+
accountId: account.accountId,
|
|
209
|
+
name: account.nickname || account.userID,
|
|
210
|
+
enabled: account.enabled,
|
|
211
|
+
configured: !!account.token,
|
|
212
|
+
running: runtime?.running ?? false,
|
|
213
|
+
lastStartAt: runtime?.lastStartAt ?? null,
|
|
214
|
+
lastStopAt: runtime?.lastStopAt ?? null,
|
|
215
|
+
lastError: runtime?.lastError ?? null,
|
|
216
|
+
probe,
|
|
217
|
+
}),
|
|
218
|
+
collectStatusIssues: (accounts) => {
|
|
219
|
+
const issues: ChannelStatusIssue[] = [];
|
|
220
|
+
for (const snap of accounts) {
|
|
221
|
+
if (snap.lastError) {
|
|
222
|
+
issues.push({
|
|
223
|
+
channel: "teamily",
|
|
224
|
+
accountId: snap.accountId,
|
|
225
|
+
kind: "runtime",
|
|
226
|
+
message: snap.lastError,
|
|
227
|
+
fix: "Check that the Teamily server is running and accessible",
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return issues;
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
gateway: {
|
|
235
|
+
startAccount: async (ctx) => {
|
|
236
|
+
const { accountId, account, log } = ctx;
|
|
237
|
+
|
|
238
|
+
if (!account.token) {
|
|
239
|
+
log?.warn?.(`Teamily account ${accountId} not configured (missing token)`);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
log?.info?.(`Starting Teamily channel (account: ${accountId})`);
|
|
244
|
+
|
|
245
|
+
const MEDIA_MAX_BYTES = 5 * 1024 * 1024; // 5 MB
|
|
246
|
+
const historyLimit = DEFAULT_GROUP_HISTORY_LIMIT; // 50 messages per group
|
|
247
|
+
const groupHistories = new Map<string, HistoryEntry[]>();
|
|
248
|
+
|
|
249
|
+
const stopFn = startTeamilyMonitoring(account, async (message) => {
|
|
250
|
+
try {
|
|
251
|
+
const rt = getTeamilyRuntime();
|
|
252
|
+
const currentCfg = rt.config.loadConfig();
|
|
253
|
+
|
|
254
|
+
const isGroup = isGroupSession(message.sessionType);
|
|
255
|
+
const from = message.sendID;
|
|
256
|
+
const rawText = message.content?.text || "";
|
|
257
|
+
|
|
258
|
+
log?.info?.(
|
|
259
|
+
`[${accountId}] Incoming message: sessionType=${message.sessionType}, isGroup=${isGroup}, ` +
|
|
260
|
+
`from=${from}, recvID=${message.recvID}, isAtSelf=${message.isAtSelf ?? false}`,
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
const historyKey = isGroup ? `teamily:group:${message.recvID}` : undefined;
|
|
264
|
+
|
|
265
|
+
// In group chats, buffer non-@-mention text-only messages for context.
|
|
266
|
+
// Media messages (picture/video/audio) are always dispatched — OpenIM sends
|
|
267
|
+
// @-mention text and media as separate messages, so a PICTURE following an
|
|
268
|
+
// AT_TEXT won't have isAtSelf=true. Dispatching media keeps group image
|
|
269
|
+
// handling consistent with DM behavior.
|
|
270
|
+
const hasMedia = !!(
|
|
271
|
+
message.content?.picture ||
|
|
272
|
+
message.content?.video ||
|
|
273
|
+
message.content?.audio
|
|
274
|
+
);
|
|
275
|
+
if (isGroup && !message.isAtSelf && !hasMedia) {
|
|
276
|
+
if (historyKey && rawText) {
|
|
277
|
+
recordPendingHistoryEntryIfEnabled({
|
|
278
|
+
historyMap: groupHistories,
|
|
279
|
+
historyKey,
|
|
280
|
+
limit: historyLimit,
|
|
281
|
+
entry: {
|
|
282
|
+
sender: from,
|
|
283
|
+
body: rawText,
|
|
284
|
+
timestamp: message.sendTime,
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const sessionKey = isGroup ? `teamily:group:${message.recvID}` : `teamily:${from}`;
|
|
292
|
+
|
|
293
|
+
let mediaUrl: string | undefined;
|
|
294
|
+
if (message.content?.picture?.sourcePicture?.url) {
|
|
295
|
+
mediaUrl = message.content.picture.sourcePicture.url;
|
|
296
|
+
} else if (message.content?.video?.videoUrl) {
|
|
297
|
+
mediaUrl = message.content.video.videoUrl;
|
|
298
|
+
} else if (message.content?.audio?.sourceUrl) {
|
|
299
|
+
mediaUrl = message.content.audio.sourceUrl;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Download remote media to a local temp file so the agent recognises
|
|
303
|
+
// image-only messages (hasMediaAttachment checks MediaPath, not MediaUrl).
|
|
304
|
+
let mediaPath: string | undefined;
|
|
305
|
+
let mediaType: string | undefined;
|
|
306
|
+
if (mediaUrl) {
|
|
307
|
+
try {
|
|
308
|
+
const fetched = await rt.channel.media.fetchRemoteMedia({ url: mediaUrl, maxBytes: MEDIA_MAX_BYTES });
|
|
309
|
+
const saved = await rt.channel.media.saveMediaBuffer(
|
|
310
|
+
fetched.buffer,
|
|
311
|
+
fetched.contentType,
|
|
312
|
+
"inbound",
|
|
313
|
+
MEDIA_MAX_BYTES,
|
|
314
|
+
);
|
|
315
|
+
mediaPath = saved.path;
|
|
316
|
+
mediaType = saved.contentType;
|
|
317
|
+
} catch (err) {
|
|
318
|
+
log?.warn?.(`[${accountId}] Failed to download Teamily media: ${String(err)}`);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// For group @-mention messages, prepend buffered history as context.
|
|
323
|
+
const body =
|
|
324
|
+
isGroup && historyKey
|
|
325
|
+
? buildPendingHistoryContextFromMap({
|
|
326
|
+
historyMap: groupHistories,
|
|
327
|
+
historyKey,
|
|
328
|
+
limit: historyLimit,
|
|
329
|
+
currentMessage: rawText,
|
|
330
|
+
formatEntry: (entry) => `${entry.sender}: ${entry.body}`,
|
|
331
|
+
})
|
|
332
|
+
: rawText;
|
|
333
|
+
|
|
334
|
+
const replyTarget = isGroup ? `group:${message.recvID}` : from;
|
|
335
|
+
const msgCtx = {
|
|
336
|
+
Body: body,
|
|
337
|
+
From: from,
|
|
338
|
+
To: account.userID,
|
|
339
|
+
SessionKey: sessionKey,
|
|
340
|
+
AccountId: accountId,
|
|
341
|
+
Provider: "teamily" as const,
|
|
342
|
+
Surface: "teamily" as const,
|
|
343
|
+
OriginatingChannel: "teamily" as const,
|
|
344
|
+
OriginatingTo: replyTarget,
|
|
345
|
+
ChatType: isGroup ? "group" : "direct",
|
|
346
|
+
MediaUrl: mediaUrl,
|
|
347
|
+
MediaPath: mediaPath,
|
|
348
|
+
MediaType: mediaType,
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
log?.info?.(
|
|
352
|
+
`[${accountId}] Dispatching reply: sessionKey=${sessionKey}, chatType=${isGroup ? "group" : "direct"}, replyTarget=${replyTarget}`,
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
356
|
+
ctx: msgCtx,
|
|
357
|
+
cfg: currentCfg,
|
|
358
|
+
dispatcherOptions: {
|
|
359
|
+
deliver: async (payload: { text?: string; body?: string }) => {
|
|
360
|
+
const replyText = payload?.text ?? payload?.body;
|
|
361
|
+
if (replyText) {
|
|
362
|
+
const monitor = getTeamilyMonitor(accountId);
|
|
363
|
+
if (!monitor) throw new Error(`Teamily monitor not running for account ${accountId}`);
|
|
364
|
+
log?.info?.(`[${accountId}] Sending reply to: ${replyTarget} (isGroup=${isGroup})`);
|
|
365
|
+
const target = normalizeTeamilyTarget(replyTarget);
|
|
366
|
+
await monitor.sendText(target, replyText);
|
|
367
|
+
}
|
|
368
|
+
},
|
|
369
|
+
onReplyStart: () => {
|
|
370
|
+
log?.info?.(`Agent reply started for ${from}`);
|
|
371
|
+
},
|
|
372
|
+
},
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
log?.info?.(`[${accountId}] Dispatch completed for ${sessionKey}`);
|
|
376
|
+
|
|
377
|
+
// Clear history buffer after dispatch so context doesn't repeat.
|
|
378
|
+
if (isGroup && historyKey) {
|
|
379
|
+
clearHistoryEntriesIfEnabled({
|
|
380
|
+
historyMap: groupHistories,
|
|
381
|
+
historyKey,
|
|
382
|
+
limit: historyLimit,
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
} catch (err) {
|
|
386
|
+
log?.error?.(
|
|
387
|
+
`[${accountId}] Error handling message from ${message.sendID}: ${err instanceof Error ? err.stack || err.message : String(err)}`,
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
// Respect abort signal from the gateway framework
|
|
393
|
+
ctx.abortSignal.addEventListener("abort", () => {
|
|
394
|
+
stopFn();
|
|
395
|
+
stopTeamilyMonitoring(accountId);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
// Block until aborted — monitor runs indefinitely
|
|
399
|
+
await new Promise<void>((resolve) => {
|
|
400
|
+
ctx.abortSignal.addEventListener("abort", () => resolve());
|
|
401
|
+
});
|
|
402
|
+
},
|
|
403
|
+
},
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
function applyTeamilyAccountConfig(params: {
|
|
407
|
+
cfg: CoreConfig;
|
|
408
|
+
accountId: string;
|
|
409
|
+
input: Record<string, unknown>;
|
|
410
|
+
}): CoreConfig {
|
|
411
|
+
const { cfg, accountId, input } = params;
|
|
412
|
+
const existing = cfg.channels?.teamily;
|
|
413
|
+
const server = existing?.server ?? { platformUrl: "", apiURL: "", wsURL: "" };
|
|
414
|
+
const accounts = existing?.accounts ?? {};
|
|
415
|
+
|
|
416
|
+
const accountUpdate: Record<string, unknown> = {};
|
|
417
|
+
if (input.userID) accountUpdate.userID = String(input.userID);
|
|
418
|
+
if (input.token) accountUpdate.token = String(input.token);
|
|
419
|
+
if (input.nickname) accountUpdate.nickname = String(input.nickname);
|
|
420
|
+
if (input.faceURL) accountUpdate.faceURL = String(input.faceURL);
|
|
421
|
+
|
|
422
|
+
const serverUpdate: Record<string, string> = {};
|
|
423
|
+
if (input.platformUrl) serverUpdate.platformUrl = String(input.platformUrl);
|
|
424
|
+
if (input.apiURL) serverUpdate.apiURL = String(input.apiURL);
|
|
425
|
+
if (input.wsURL) serverUpdate.wsURL = String(input.wsURL);
|
|
426
|
+
|
|
427
|
+
return {
|
|
428
|
+
...cfg,
|
|
429
|
+
channels: {
|
|
430
|
+
...cfg.channels,
|
|
431
|
+
teamily: {
|
|
432
|
+
enabled: true,
|
|
433
|
+
server: { ...server, ...serverUpdate },
|
|
434
|
+
accounts: {
|
|
435
|
+
...accounts,
|
|
436
|
+
[accountId]: {
|
|
437
|
+
...accounts[accountId],
|
|
438
|
+
...accountUpdate,
|
|
439
|
+
},
|
|
440
|
+
},
|
|
441
|
+
},
|
|
442
|
+
},
|
|
443
|
+
} as CoreConfig;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function requireMonitor(accountId?: string | null) {
|
|
447
|
+
const id = accountId || "default";
|
|
448
|
+
const monitor = getTeamilyMonitor(id);
|
|
449
|
+
if (!monitor) {
|
|
450
|
+
throw new Error(`Teamily gateway not running for account "${id}" — outbound requires an active gateway`);
|
|
451
|
+
}
|
|
452
|
+
return monitor;
|
|
453
|
+
}
|