@botcord/openclaw-plugin 0.0.2 → 0.0.3
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 +8 -0
- package/index.ts +43 -2
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/skills/botcord/SKILL.md +45 -0
- package/src/client.ts +70 -2
- package/src/commands/healthcheck.ts +19 -2
- package/src/commands/register.ts +134 -11
- package/src/commands/token.ts +1 -2
- package/src/credentials.ts +14 -2
- package/src/hub-url.ts +41 -0
- package/src/inbound.ts +16 -2
- package/src/loop-risk.ts +409 -0
- package/src/tools/rooms.ts +6 -0
- package/src/tools/subscription.ts +176 -0
- package/src/types.ts +61 -0
- package/src/ws-client.ts +2 -2
package/src/loop-risk.ts
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
type AgentMessageLike = {
|
|
2
|
+
role?: unknown;
|
|
3
|
+
content?: unknown;
|
|
4
|
+
timestamp?: unknown;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
type UserTurn = {
|
|
8
|
+
text: string;
|
|
9
|
+
normalized: string;
|
|
10
|
+
timestamp?: number;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type OutboundSample = {
|
|
14
|
+
text: string;
|
|
15
|
+
normalized: string;
|
|
16
|
+
timestamp: number;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type BotCordLoopRiskReason = {
|
|
20
|
+
id: "high_turn_rate" | "short_ack_tail" | "repeated_outbound";
|
|
21
|
+
summary: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type BotCordLoopRiskEvaluation = {
|
|
25
|
+
reasons: BotCordLoopRiskReason[];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const outboundBySession = new Map<string, OutboundSample[]>();
|
|
29
|
+
|
|
30
|
+
const TURN_WINDOW_MS = 2 * 60_000;
|
|
31
|
+
const TURN_THRESHOLD = 8;
|
|
32
|
+
const ALTERNATION_THRESHOLD = 6;
|
|
33
|
+
const MIN_TURNS_PER_SIDE = 3;
|
|
34
|
+
|
|
35
|
+
const OUTBOUND_MAX_AGE_MS = 10 * 60_000;
|
|
36
|
+
const MAX_TRACKED_OUTBOUND = 6;
|
|
37
|
+
const SHORT_ACK_MAX_CHARS = 48;
|
|
38
|
+
const MIN_REPEAT_TEXT_CHARS = 6;
|
|
39
|
+
const OUTBOUND_SIMILARITY_THRESHOLD = 0.88;
|
|
40
|
+
|
|
41
|
+
const ENGLISH_ACK_OR_CLOSURE = new Set([
|
|
42
|
+
"ok",
|
|
43
|
+
"okay",
|
|
44
|
+
"got it",
|
|
45
|
+
"thanks",
|
|
46
|
+
"thank you",
|
|
47
|
+
"noted",
|
|
48
|
+
"understood",
|
|
49
|
+
"sounds good",
|
|
50
|
+
"sgtm",
|
|
51
|
+
"roger",
|
|
52
|
+
"copy",
|
|
53
|
+
"will do",
|
|
54
|
+
"all good",
|
|
55
|
+
"no worries",
|
|
56
|
+
"bye",
|
|
57
|
+
"goodbye",
|
|
58
|
+
"see you",
|
|
59
|
+
"talk later",
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
const CHINESE_ACK_OR_CLOSURE = new Set([
|
|
63
|
+
"收到",
|
|
64
|
+
"好的",
|
|
65
|
+
"好",
|
|
66
|
+
"行",
|
|
67
|
+
"嗯",
|
|
68
|
+
"嗯嗯",
|
|
69
|
+
"明白",
|
|
70
|
+
"明白了",
|
|
71
|
+
"知道了",
|
|
72
|
+
"谢谢",
|
|
73
|
+
"谢谢你",
|
|
74
|
+
"感谢",
|
|
75
|
+
"辛苦了",
|
|
76
|
+
"先这样",
|
|
77
|
+
"回头聊",
|
|
78
|
+
"有需要再说",
|
|
79
|
+
"没问题",
|
|
80
|
+
"了解",
|
|
81
|
+
"好嘞",
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
function resolveTimestamp(value: unknown): number | undefined {
|
|
85
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
86
|
+
if (typeof value === "string") {
|
|
87
|
+
const numeric = Number(value);
|
|
88
|
+
if (Number.isFinite(numeric)) return numeric;
|
|
89
|
+
const parsed = Date.parse(value);
|
|
90
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
91
|
+
}
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function extractTextFromContent(content: unknown): string {
|
|
96
|
+
if (typeof content === "string") return content;
|
|
97
|
+
if (Array.isArray(content)) {
|
|
98
|
+
return content
|
|
99
|
+
.map((part) => {
|
|
100
|
+
if (typeof part === "string") return part;
|
|
101
|
+
if (!part || typeof part !== "object") return "";
|
|
102
|
+
const record = part as Record<string, unknown>;
|
|
103
|
+
if (record.type === "text" && typeof record.text === "string") return record.text;
|
|
104
|
+
if (typeof record.text === "string") return record.text;
|
|
105
|
+
return "";
|
|
106
|
+
})
|
|
107
|
+
.filter(Boolean)
|
|
108
|
+
.join("\n");
|
|
109
|
+
}
|
|
110
|
+
if (content && typeof content === "object") {
|
|
111
|
+
const record = content as Record<string, unknown>;
|
|
112
|
+
if (typeof record.text === "string") return record.text;
|
|
113
|
+
}
|
|
114
|
+
return "";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function stripBotCordPromptScaffolding(text: string): string {
|
|
118
|
+
const filtered = text
|
|
119
|
+
.split(/\r?\n/)
|
|
120
|
+
.map((line) => line.trim())
|
|
121
|
+
.filter((line) => {
|
|
122
|
+
if (!line) return false;
|
|
123
|
+
if (line.startsWith("[BotCord Message]")) return false;
|
|
124
|
+
if (line.startsWith("[BotCord Notification]")) return false;
|
|
125
|
+
if (line.startsWith("[Room Rule]")) return false;
|
|
126
|
+
if (line.startsWith("[In group chats, do NOT reply")) return false;
|
|
127
|
+
if (line.startsWith("[If the conversation has naturally concluded")) return false;
|
|
128
|
+
if (line.includes('reply with exactly "NO_REPLY"')) return false;
|
|
129
|
+
return true;
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
return filtered.join("\n").trim();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function normalizeLoopText(text: string): string {
|
|
136
|
+
return stripBotCordPromptScaffolding(text)
|
|
137
|
+
.toLowerCase()
|
|
138
|
+
.replace(/https?:\/\/\S+/gu, " ")
|
|
139
|
+
.replace(/[^\p{L}\p{N}\s]/gu, " ")
|
|
140
|
+
.replace(/\s+/gu, " ")
|
|
141
|
+
.trim();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function isShortAckOrClosure(text: string): boolean {
|
|
145
|
+
const normalized = normalizeLoopText(text);
|
|
146
|
+
if (!normalized || normalized.length > SHORT_ACK_MAX_CHARS) return false;
|
|
147
|
+
return ENGLISH_ACK_OR_CLOSURE.has(normalized) || CHINESE_ACK_OR_CLOSURE.has(normalized);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function trigramSet(text: string): Set<string> {
|
|
151
|
+
if (text.length <= 3) return new Set([text]);
|
|
152
|
+
const grams = new Set<string>();
|
|
153
|
+
for (let i = 0; i <= text.length - 3; i++) {
|
|
154
|
+
grams.add(text.slice(i, i + 3));
|
|
155
|
+
}
|
|
156
|
+
return grams;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function jaccardSimilarity(a: string, b: string): number {
|
|
160
|
+
if (!a || !b) return 0;
|
|
161
|
+
if (a === b) return 1;
|
|
162
|
+
const aSet = trigramSet(a);
|
|
163
|
+
const bSet = trigramSet(b);
|
|
164
|
+
let intersection = 0;
|
|
165
|
+
for (const gram of aSet) {
|
|
166
|
+
if (bSet.has(gram)) intersection++;
|
|
167
|
+
}
|
|
168
|
+
const union = aSet.size + bSet.size - intersection;
|
|
169
|
+
return union === 0 ? 0 : intersection / union;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function areOutboundTextsHighlySimilar(a: string, b: string): boolean {
|
|
173
|
+
if (!a || !b) return false;
|
|
174
|
+
if (a === b) return true;
|
|
175
|
+
if (a.length < MIN_REPEAT_TEXT_CHARS || b.length < MIN_REPEAT_TEXT_CHARS) return false;
|
|
176
|
+
return jaccardSimilarity(a, b) >= OUTBOUND_SIMILARITY_THRESHOLD;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function extractHistoricalUserTurns(messages: unknown[]): UserTurn[] {
|
|
180
|
+
const result: UserTurn[] = [];
|
|
181
|
+
for (const message of messages) {
|
|
182
|
+
if (!message || typeof message !== "object") continue;
|
|
183
|
+
const candidate = message as AgentMessageLike;
|
|
184
|
+
if (candidate.role !== "user") continue;
|
|
185
|
+
const rawText = extractTextFromContent(candidate.content);
|
|
186
|
+
const text = stripBotCordPromptScaffolding(rawText);
|
|
187
|
+
if (!text) continue;
|
|
188
|
+
result.push({
|
|
189
|
+
text,
|
|
190
|
+
normalized: normalizeLoopText(text),
|
|
191
|
+
timestamp: resolveTimestamp(candidate.timestamp),
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
return result;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function looksLikeBotCordPrompt(prompt: string): boolean {
|
|
198
|
+
return prompt.includes("[BotCord Message]") || prompt.includes("[BotCord Notification]");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function pruneOutboundSamples(sessionKey: string, now: number): OutboundSample[] {
|
|
202
|
+
const existing = outboundBySession.get(sessionKey) ?? [];
|
|
203
|
+
const next = existing.filter((sample) => now - sample.timestamp <= OUTBOUND_MAX_AGE_MS);
|
|
204
|
+
if (next.length === 0) {
|
|
205
|
+
outboundBySession.delete(sessionKey);
|
|
206
|
+
return [];
|
|
207
|
+
}
|
|
208
|
+
outboundBySession.set(sessionKey, next);
|
|
209
|
+
return next;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function recordOutboundSample(sessionKey: string, sample: OutboundSample): void {
|
|
213
|
+
const existing = pruneOutboundSamples(sessionKey, sample.timestamp);
|
|
214
|
+
const next = [...existing, sample].slice(-MAX_TRACKED_OUTBOUND);
|
|
215
|
+
outboundBySession.set(sessionKey, next);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function buildTurnTimeline(params: {
|
|
219
|
+
historicalUserTurns: UserTurn[];
|
|
220
|
+
currentPrompt: string;
|
|
221
|
+
outbound: OutboundSample[];
|
|
222
|
+
now: number;
|
|
223
|
+
}): Array<{ role: "user" | "assistant"; timestamp: number }> {
|
|
224
|
+
const { historicalUserTurns, currentPrompt, outbound, now } = params;
|
|
225
|
+
const turns: Array<{ role: "user" | "assistant"; timestamp: number }> = [];
|
|
226
|
+
|
|
227
|
+
for (const turn of historicalUserTurns) {
|
|
228
|
+
if (turn.timestamp !== undefined && now - turn.timestamp <= TURN_WINDOW_MS) {
|
|
229
|
+
turns.push({ role: "user", timestamp: turn.timestamp });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (stripBotCordPromptScaffolding(currentPrompt)) {
|
|
234
|
+
turns.push({ role: "user", timestamp: now });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
for (const sample of outbound) {
|
|
238
|
+
if (now - sample.timestamp <= TURN_WINDOW_MS) {
|
|
239
|
+
turns.push({ role: "assistant", timestamp: sample.timestamp });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
turns.sort((a, b) => a.timestamp - b.timestamp);
|
|
244
|
+
return turns;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function detectHighTurnRate(params: {
|
|
248
|
+
historicalUserTurns: UserTurn[];
|
|
249
|
+
currentPrompt: string;
|
|
250
|
+
outbound: OutboundSample[];
|
|
251
|
+
now: number;
|
|
252
|
+
}): BotCordLoopRiskReason | undefined {
|
|
253
|
+
const timeline = buildTurnTimeline(params);
|
|
254
|
+
if (timeline.length < TURN_THRESHOLD) return undefined;
|
|
255
|
+
|
|
256
|
+
let userTurns = 0;
|
|
257
|
+
let assistantTurns = 0;
|
|
258
|
+
let alternations = 0;
|
|
259
|
+
|
|
260
|
+
for (let i = 0; i < timeline.length; i++) {
|
|
261
|
+
if (timeline[i]?.role === "user") userTurns++;
|
|
262
|
+
if (timeline[i]?.role === "assistant") assistantTurns++;
|
|
263
|
+
if (i > 0 && timeline[i]?.role !== timeline[i - 1]?.role) alternations++;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (
|
|
267
|
+
userTurns >= MIN_TURNS_PER_SIDE &&
|
|
268
|
+
assistantTurns >= MIN_TURNS_PER_SIDE &&
|
|
269
|
+
alternations >= ALTERNATION_THRESHOLD
|
|
270
|
+
) {
|
|
271
|
+
return {
|
|
272
|
+
id: "high_turn_rate",
|
|
273
|
+
summary: `same session shows ${timeline.length} user/assistant turns within ${Math.round(TURN_WINDOW_MS / 1000)}s`,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function detectShortAckTail(params: {
|
|
281
|
+
historicalUserTurns: UserTurn[];
|
|
282
|
+
currentPrompt: string;
|
|
283
|
+
}): BotCordLoopRiskReason | undefined {
|
|
284
|
+
const currentPrompt = stripBotCordPromptScaffolding(params.currentPrompt);
|
|
285
|
+
const userTexts = params.historicalUserTurns.map((turn) => turn.text);
|
|
286
|
+
if (currentPrompt) userTexts.push(currentPrompt);
|
|
287
|
+
const tail = userTexts.slice(-2);
|
|
288
|
+
if (tail.length < 2) return undefined;
|
|
289
|
+
if (tail.every((text) => isShortAckOrClosure(text))) {
|
|
290
|
+
return {
|
|
291
|
+
id: "short_ack_tail",
|
|
292
|
+
summary: "the last two inbound user messages are short acknowledgements or closure phrases",
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
return undefined;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function detectRepeatedOutbound(outbound: OutboundSample[]): BotCordLoopRiskReason | undefined {
|
|
299
|
+
const recent = outbound.slice(-3);
|
|
300
|
+
if (recent.length < 2) return undefined;
|
|
301
|
+
|
|
302
|
+
const last = recent[recent.length - 1];
|
|
303
|
+
if (!last) return undefined;
|
|
304
|
+
|
|
305
|
+
const previous = recent.slice(0, -1);
|
|
306
|
+
const exactMatches = previous.filter((sample) => sample.normalized === last.normalized).length;
|
|
307
|
+
const similarMatches = previous.filter((sample) =>
|
|
308
|
+
areOutboundTextsHighlySimilar(sample.normalized, last.normalized)
|
|
309
|
+
).length;
|
|
310
|
+
|
|
311
|
+
if (exactMatches >= 1 || (recent.length >= 3 && similarMatches >= 2)) {
|
|
312
|
+
return {
|
|
313
|
+
id: "repeated_outbound",
|
|
314
|
+
summary: "recent botcord_send texts in this session are highly similar",
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return undefined;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function shouldRunBotCordLoopRiskCheck(params: {
|
|
322
|
+
channelId?: string;
|
|
323
|
+
prompt: string;
|
|
324
|
+
trigger?: string;
|
|
325
|
+
}): boolean {
|
|
326
|
+
if (params.trigger && params.trigger !== "user") return false;
|
|
327
|
+
return params.channelId === "botcord" || looksLikeBotCordPrompt(params.prompt);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function recordBotCordOutboundText(params: {
|
|
331
|
+
sessionKey?: string;
|
|
332
|
+
text?: unknown;
|
|
333
|
+
timestamp?: number;
|
|
334
|
+
}): void {
|
|
335
|
+
const sessionKey = params.sessionKey?.trim();
|
|
336
|
+
const rawText = typeof params.text === "string" ? params.text.trim() : "";
|
|
337
|
+
if (!sessionKey || !rawText) return;
|
|
338
|
+
const normalized = normalizeLoopText(rawText);
|
|
339
|
+
if (!normalized) return;
|
|
340
|
+
const timestamp = params.timestamp ?? Date.now();
|
|
341
|
+
recordOutboundSample(sessionKey, { text: rawText, normalized, timestamp });
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export function clearBotCordLoopRiskSession(sessionKey?: string): void {
|
|
345
|
+
if (!sessionKey) return;
|
|
346
|
+
outboundBySession.delete(sessionKey);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export function evaluateBotCordLoopRisk(params: {
|
|
350
|
+
prompt: string;
|
|
351
|
+
messages: unknown[];
|
|
352
|
+
sessionKey?: string;
|
|
353
|
+
now?: number;
|
|
354
|
+
}): BotCordLoopRiskEvaluation {
|
|
355
|
+
const now = params.now ?? Date.now();
|
|
356
|
+
const historicalUserTurns = extractHistoricalUserTurns(params.messages);
|
|
357
|
+
const outbound = params.sessionKey ? pruneOutboundSamples(params.sessionKey, now) : [];
|
|
358
|
+
|
|
359
|
+
const reasons = [
|
|
360
|
+
detectHighTurnRate({
|
|
361
|
+
historicalUserTurns,
|
|
362
|
+
currentPrompt: params.prompt,
|
|
363
|
+
outbound,
|
|
364
|
+
now,
|
|
365
|
+
}),
|
|
366
|
+
detectShortAckTail({
|
|
367
|
+
historicalUserTurns,
|
|
368
|
+
currentPrompt: params.prompt,
|
|
369
|
+
}),
|
|
370
|
+
detectRepeatedOutbound(outbound),
|
|
371
|
+
].filter((reason): reason is BotCordLoopRiskReason => Boolean(reason));
|
|
372
|
+
|
|
373
|
+
return { reasons };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export function buildBotCordLoopRiskPrompt(params: {
|
|
377
|
+
prompt: string;
|
|
378
|
+
messages: unknown[];
|
|
379
|
+
sessionKey?: string;
|
|
380
|
+
now?: number;
|
|
381
|
+
}): string | undefined {
|
|
382
|
+
const evaluation = evaluateBotCordLoopRisk(params);
|
|
383
|
+
if (evaluation.reasons.length === 0) return undefined;
|
|
384
|
+
|
|
385
|
+
const lines = [
|
|
386
|
+
"[BotCord loop-risk check]",
|
|
387
|
+
"Observed signals:",
|
|
388
|
+
...evaluation.reasons.map((reason) => `- ${reason.summary}`),
|
|
389
|
+
"",
|
|
390
|
+
"Before sending any BotCord reply, verify that it adds new information, concrete progress, a blocking question, or a final result/error.",
|
|
391
|
+
'If it does not, reply with exactly "NO_REPLY" and nothing else.',
|
|
392
|
+
"Do not send courtesy-only acknowledgements or mirrored sign-offs.",
|
|
393
|
+
];
|
|
394
|
+
|
|
395
|
+
return lines.join("\n");
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export function didBotCordSendSucceed(result: unknown, error?: string): boolean {
|
|
399
|
+
if (error) return false;
|
|
400
|
+
if (!result || typeof result !== "object") return true;
|
|
401
|
+
const record = result as Record<string, unknown>;
|
|
402
|
+
if (record.ok === true) return true;
|
|
403
|
+
if (typeof record.error === "string" && record.error.trim()) return false;
|
|
404
|
+
return true;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export function resetBotCordLoopRiskStateForTests(): void {
|
|
408
|
+
outboundBySession.clear();
|
|
409
|
+
}
|
package/src/tools/rooms.ts
CHANGED
|
@@ -40,6 +40,10 @@ export function createRoomsTool() {
|
|
|
40
40
|
type: "string" as const,
|
|
41
41
|
description: "Room description — for create, update",
|
|
42
42
|
},
|
|
43
|
+
rule: {
|
|
44
|
+
type: "string" as const,
|
|
45
|
+
description: "Room rule/instructions — for create, update",
|
|
46
|
+
},
|
|
43
47
|
visibility: {
|
|
44
48
|
type: "string" as const,
|
|
45
49
|
enum: ["private", "public"],
|
|
@@ -94,6 +98,7 @@ export function createRoomsTool() {
|
|
|
94
98
|
return await client.createRoom({
|
|
95
99
|
name: args.name,
|
|
96
100
|
description: args.description,
|
|
101
|
+
rule: args.rule,
|
|
97
102
|
visibility: args.visibility || "private",
|
|
98
103
|
join_policy: args.join_policy,
|
|
99
104
|
default_send: args.default_send,
|
|
@@ -111,6 +116,7 @@ export function createRoomsTool() {
|
|
|
111
116
|
return await client.updateRoom(args.room_id, {
|
|
112
117
|
name: args.name,
|
|
113
118
|
description: args.description,
|
|
119
|
+
rule: args.rule,
|
|
114
120
|
visibility: args.visibility,
|
|
115
121
|
join_policy: args.join_policy,
|
|
116
122
|
default_send: args.default_send,
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* botcord_subscription — Create and manage coin-priced subscription products.
|
|
3
|
+
*/
|
|
4
|
+
import {
|
|
5
|
+
getSingleAccountModeError,
|
|
6
|
+
resolveAccountConfig,
|
|
7
|
+
isAccountConfigured,
|
|
8
|
+
} from "../config.js";
|
|
9
|
+
import { BotCordClient } from "../client.js";
|
|
10
|
+
import { getConfig as getAppConfig } from "../runtime.js";
|
|
11
|
+
|
|
12
|
+
function formatProduct(product: any): string {
|
|
13
|
+
return [
|
|
14
|
+
`Product: ${product.product_id}`,
|
|
15
|
+
`Owner: ${product.owner_agent_id}`,
|
|
16
|
+
`Name: ${product.name}`,
|
|
17
|
+
`Amount: ${product.amount_minor} minor units`,
|
|
18
|
+
`Interval: ${product.billing_interval}`,
|
|
19
|
+
`Status: ${product.status}`,
|
|
20
|
+
].join("\n");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function formatSubscription(subscription: any): string {
|
|
24
|
+
return [
|
|
25
|
+
`Subscription: ${subscription.subscription_id}`,
|
|
26
|
+
`Product: ${subscription.product_id}`,
|
|
27
|
+
`Subscriber: ${subscription.subscriber_agent_id}`,
|
|
28
|
+
`Provider: ${subscription.provider_agent_id}`,
|
|
29
|
+
`Amount: ${subscription.amount_minor} minor units`,
|
|
30
|
+
`Interval: ${subscription.billing_interval}`,
|
|
31
|
+
`Status: ${subscription.status}`,
|
|
32
|
+
`Next charge: ${subscription.next_charge_at}`,
|
|
33
|
+
].join("\n");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function formatProductList(products: any[]): string {
|
|
37
|
+
if (products.length === 0) return "No subscription products found.";
|
|
38
|
+
return products.map((product) => formatProduct(product)).join("\n\n");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function formatSubscriptionList(subscriptions: any[]): string {
|
|
42
|
+
if (subscriptions.length === 0) return "No subscriptions found.";
|
|
43
|
+
return subscriptions.map((subscription) => formatSubscription(subscription)).join("\n\n");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function createSubscriptionTool() {
|
|
47
|
+
return {
|
|
48
|
+
name: "botcord_subscription",
|
|
49
|
+
description:
|
|
50
|
+
"Create subscription products priced in BotCord coin, subscribe to products, list active subscriptions, and manage cancellation or product archiving.",
|
|
51
|
+
parameters: {
|
|
52
|
+
type: "object" as const,
|
|
53
|
+
properties: {
|
|
54
|
+
action: {
|
|
55
|
+
type: "string" as const,
|
|
56
|
+
enum: [
|
|
57
|
+
"create_product",
|
|
58
|
+
"list_my_products",
|
|
59
|
+
"list_products",
|
|
60
|
+
"archive_product",
|
|
61
|
+
"subscribe",
|
|
62
|
+
"list_my_subscriptions",
|
|
63
|
+
"list_subscribers",
|
|
64
|
+
"cancel",
|
|
65
|
+
],
|
|
66
|
+
description: "Subscription action to perform",
|
|
67
|
+
},
|
|
68
|
+
product_id: {
|
|
69
|
+
type: "string" as const,
|
|
70
|
+
description: "Product ID — for archive_product, subscribe, list_subscribers",
|
|
71
|
+
},
|
|
72
|
+
subscription_id: {
|
|
73
|
+
type: "string" as const,
|
|
74
|
+
description: "Subscription ID — for cancel",
|
|
75
|
+
},
|
|
76
|
+
name: {
|
|
77
|
+
type: "string" as const,
|
|
78
|
+
description: "Product name — for create_product",
|
|
79
|
+
},
|
|
80
|
+
description: {
|
|
81
|
+
type: "string" as const,
|
|
82
|
+
description: "Product description — for create_product",
|
|
83
|
+
},
|
|
84
|
+
amount_minor: {
|
|
85
|
+
type: "string" as const,
|
|
86
|
+
description: "Price in minor coin units — for create_product",
|
|
87
|
+
},
|
|
88
|
+
billing_interval: {
|
|
89
|
+
type: "string" as const,
|
|
90
|
+
enum: ["week", "month"],
|
|
91
|
+
description: "Billing interval — for create_product",
|
|
92
|
+
},
|
|
93
|
+
asset_code: {
|
|
94
|
+
type: "string" as const,
|
|
95
|
+
description: "Asset code — for create_product",
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
required: ["action"],
|
|
99
|
+
},
|
|
100
|
+
execute: async (toolCallId: any, args: any, signal?: any, onUpdate?: any) => {
|
|
101
|
+
const cfg = getAppConfig();
|
|
102
|
+
if (!cfg) return { error: "No configuration available" };
|
|
103
|
+
const singleAccountError = getSingleAccountModeError(cfg);
|
|
104
|
+
if (singleAccountError) return { error: singleAccountError };
|
|
105
|
+
|
|
106
|
+
const acct = resolveAccountConfig(cfg);
|
|
107
|
+
if (!isAccountConfigured(acct)) {
|
|
108
|
+
return { error: "BotCord is not configured." };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const client = new BotCordClient(acct);
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
switch (args.action) {
|
|
115
|
+
case "create_product": {
|
|
116
|
+
if (!args.name) return { error: "name is required" };
|
|
117
|
+
if (!args.amount_minor) return { error: "amount_minor is required" };
|
|
118
|
+
if (!args.billing_interval) return { error: "billing_interval is required" };
|
|
119
|
+
const product = await client.createSubscriptionProduct({
|
|
120
|
+
name: args.name,
|
|
121
|
+
description: args.description,
|
|
122
|
+
amount_minor: args.amount_minor,
|
|
123
|
+
billing_interval: args.billing_interval,
|
|
124
|
+
asset_code: args.asset_code,
|
|
125
|
+
});
|
|
126
|
+
return { result: formatProduct(product), data: product };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
case "list_my_products": {
|
|
130
|
+
const products = await client.listMySubscriptionProducts();
|
|
131
|
+
return { result: formatProductList(products), data: products };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
case "list_products": {
|
|
135
|
+
const products = await client.listSubscriptionProducts();
|
|
136
|
+
return { result: formatProductList(products), data: products };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
case "archive_product": {
|
|
140
|
+
if (!args.product_id) return { error: "product_id is required" };
|
|
141
|
+
const product = await client.archiveSubscriptionProduct(args.product_id);
|
|
142
|
+
return { result: formatProduct(product), data: product };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
case "subscribe": {
|
|
146
|
+
if (!args.product_id) return { error: "product_id is required" };
|
|
147
|
+
const subscription = await client.subscribeToProduct(args.product_id);
|
|
148
|
+
return { result: formatSubscription(subscription), data: subscription };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
case "list_my_subscriptions": {
|
|
152
|
+
const subscriptions = await client.listMySubscriptions();
|
|
153
|
+
return { result: formatSubscriptionList(subscriptions), data: subscriptions };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
case "list_subscribers": {
|
|
157
|
+
if (!args.product_id) return { error: "product_id is required" };
|
|
158
|
+
const subscriptions = await client.listProductSubscribers(args.product_id);
|
|
159
|
+
return { result: formatSubscriptionList(subscriptions), data: subscriptions };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
case "cancel": {
|
|
163
|
+
if (!args.subscription_id) return { error: "subscription_id is required" };
|
|
164
|
+
const subscription = await client.cancelSubscription(args.subscription_id);
|
|
165
|
+
return { result: formatSubscription(subscription), data: subscription };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
default:
|
|
169
|
+
return { error: `Unknown action: ${args.action}` };
|
|
170
|
+
}
|
|
171
|
+
} catch (err: any) {
|
|
172
|
+
return { error: `Subscription action failed: ${err.message}` };
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -58,6 +58,7 @@ export type InboxMessage = {
|
|
|
58
58
|
text?: string;
|
|
59
59
|
room_id?: string;
|
|
60
60
|
room_name?: string;
|
|
61
|
+
room_rule?: string | null;
|
|
61
62
|
room_member_count?: number;
|
|
62
63
|
room_member_names?: string[];
|
|
63
64
|
my_role?: string;
|
|
@@ -86,6 +87,7 @@ export type RoomInfo = {
|
|
|
86
87
|
room_id: string;
|
|
87
88
|
name: string;
|
|
88
89
|
description?: string;
|
|
90
|
+
rule?: string | null;
|
|
89
91
|
visibility: "private" | "public";
|
|
90
92
|
join_policy: "invite_only" | "open";
|
|
91
93
|
default_send: boolean;
|
|
@@ -153,8 +155,12 @@ export type WalletTransaction = {
|
|
|
153
155
|
fee_minor: string;
|
|
154
156
|
from_agent_id: string | null;
|
|
155
157
|
to_agent_id: string | null;
|
|
158
|
+
reference_type: string | null;
|
|
159
|
+
reference_id: string | null;
|
|
160
|
+
idempotency_key: string | null;
|
|
156
161
|
metadata_json: string | null;
|
|
157
162
|
created_at: string;
|
|
163
|
+
updated_at: string;
|
|
158
164
|
completed_at: string | null;
|
|
159
165
|
};
|
|
160
166
|
|
|
@@ -201,3 +207,58 @@ export type WithdrawalResponse = {
|
|
|
201
207
|
reviewed_at: string | null;
|
|
202
208
|
completed_at: string | null;
|
|
203
209
|
};
|
|
210
|
+
|
|
211
|
+
export type BillingInterval = "week" | "month";
|
|
212
|
+
|
|
213
|
+
export type SubscriptionProductStatus = "active" | "archived";
|
|
214
|
+
|
|
215
|
+
export type SubscriptionStatus = "active" | "past_due" | "cancelled";
|
|
216
|
+
|
|
217
|
+
export type SubscriptionChargeAttemptStatus = "pending" | "succeeded" | "failed";
|
|
218
|
+
|
|
219
|
+
export type SubscriptionProduct = {
|
|
220
|
+
product_id: string;
|
|
221
|
+
owner_agent_id: string;
|
|
222
|
+
name: string;
|
|
223
|
+
description: string;
|
|
224
|
+
asset_code: string;
|
|
225
|
+
amount_minor: string;
|
|
226
|
+
billing_interval: BillingInterval;
|
|
227
|
+
status: SubscriptionProductStatus;
|
|
228
|
+
created_at: string;
|
|
229
|
+
updated_at: string;
|
|
230
|
+
archived_at: string | null;
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
export type Subscription = {
|
|
234
|
+
subscription_id: string;
|
|
235
|
+
product_id: string;
|
|
236
|
+
subscriber_agent_id: string;
|
|
237
|
+
provider_agent_id: string;
|
|
238
|
+
asset_code: string;
|
|
239
|
+
amount_minor: string;
|
|
240
|
+
billing_interval: BillingInterval;
|
|
241
|
+
status: SubscriptionStatus;
|
|
242
|
+
current_period_start: string;
|
|
243
|
+
current_period_end: string;
|
|
244
|
+
next_charge_at: string;
|
|
245
|
+
cancel_at_period_end: boolean;
|
|
246
|
+
cancelled_at: string | null;
|
|
247
|
+
last_charged_at: string | null;
|
|
248
|
+
last_charge_tx_id: string | null;
|
|
249
|
+
consecutive_failed_attempts: number;
|
|
250
|
+
created_at: string;
|
|
251
|
+
updated_at: string;
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
export type SubscriptionChargeAttempt = {
|
|
255
|
+
attempt_id: string;
|
|
256
|
+
subscription_id: string;
|
|
257
|
+
billing_cycle_key: string;
|
|
258
|
+
status: SubscriptionChargeAttemptStatus;
|
|
259
|
+
scheduled_at: string;
|
|
260
|
+
attempted_at: string | null;
|
|
261
|
+
tx_id: string | null;
|
|
262
|
+
failure_reason: string | null;
|
|
263
|
+
created_at: string;
|
|
264
|
+
};
|