twindex-openclaw-plugin 0.8.5 → 0.8.20260410

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.
@@ -1,8 +1,8 @@
1
1
  {
2
- "id": "twindex",
2
+ "id": "twindex-openclaw-plugin",
3
3
  "name": "New Lore",
4
4
  "description": "Music intelligence for AI agents. Get notified about tours, merch drops, releases, and presales for your favorite artists.",
5
- "version": "0.8.5",
5
+ "version": "0.8.20260410",
6
6
  "skills": ["./skills"],
7
7
  "configSchema": {
8
8
  "type": "object",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "twindex-openclaw-plugin",
3
- "version": "0.8.5",
3
+ "version": "0.8.20260410",
4
4
  "description": "Music intelligence for AI agents. Tours, merch drops, releases, presales.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/index.ts CHANGED
@@ -1,40 +1,26 @@
1
- import { readFileSync, writeFileSync, readdirSync, statSync } from "fs";
1
+ import { readFileSync, readdirSync, statSync } from "fs";
2
2
  import { join } from "path";
3
3
  import { homedir } from "os";
4
4
  import * as twindex from "./client.js";
5
5
  import { createNotificationService } from "./poll-service.js";
6
- import { mergedPluginConfig } from "./config.js";
6
+ import {
7
+ getPluginConfig,
8
+ migrateLegacyPluginConfig,
9
+ persistPluginConfig,
10
+ } from "./plugin-config.js";
7
11
 
8
12
  let bootstrapping = false;
9
13
 
10
14
  export default function register(api: any) {
11
- const cfg = () => mergedPluginConfig(api);
15
+ const cfg = () => getPluginConfig(api);
12
16
 
13
- function persistConfig(updates: Record<string, any>) {
14
- const nextConfig = {
15
- ...cfg(),
16
- ...updates,
17
- };
18
-
19
- // Update in-memory config when the plugin entry is available.
20
- if (api.config?.plugins?.entries?.twindex) {
21
- api.config.plugins.entries.twindex.config = nextConfig;
22
- }
17
+ if (migrateLegacyPluginConfig(api)) {
18
+ api.logger?.info?.("NewLore: migrated legacy twindex plugin config");
19
+ }
23
20
 
24
- // Write to disk — OpenClaw plugin API does not expose config.save()
25
- try {
26
- const configPath = join(homedir(), ".openclaw", "openclaw.json");
27
- const raw = readFileSync(configPath, "utf-8");
28
- const disk = JSON.parse(raw);
29
- if (!disk.plugins) disk.plugins = {};
30
- if (!disk.plugins.entries) disk.plugins.entries = {};
31
- if (!disk.plugins.entries.twindex) disk.plugins.entries.twindex = {};
32
- disk.plugins.entries.twindex.config = nextConfig;
33
- writeFileSync(configPath, JSON.stringify(disk, null, 2) + "\n", "utf-8");
34
- api.logger?.info?.("NewLore: config persisted to disk");
35
- } catch (err: any) {
36
- api.logger?.warn?.(`NewLore: failed to persist config: ${err.message}`);
37
- }
21
+ function persistConfig(updates: Record<string, any>) {
22
+ persistPluginConfig(api, updates);
23
+ api.logger?.info?.("NewLore: config persisted to disk");
38
24
  }
39
25
 
40
26
  // ── Poll-based notification service ──────────────────────────────
@@ -74,7 +60,7 @@ export default function register(api: any) {
74
60
  bootstrapping = true;
75
61
  try {
76
62
  const config = cfg();
77
- if (config.artists?.length > 0 && config.frequency && !config.apiKey) {
63
+ if (config.artists?.length > 0 && !config.apiKey) {
78
64
  const agentId =
79
65
  api.agentId ?? api.config?.agentId ?? `openclaw-${crypto.randomUUID()}`;
80
66
  const reg = await twindex.register(agentId);
@@ -92,7 +78,10 @@ export default function register(api: any) {
92
78
  }
93
79
 
94
80
  // Auto-detect chatTarget from channel config if not explicitly set
95
- const updates: Record<string, any> = { apiKey };
81
+ const updates: Record<string, any> = {
82
+ apiKey,
83
+ frequency: config.frequency ?? "periodic",
84
+ };
96
85
  const chatTarget = inferChatTarget();
97
86
  if (chatTarget) {
98
87
  updates.chatTarget = chatTarget;
@@ -158,7 +147,7 @@ export default function register(api: any) {
158
147
  chat_target: {
159
148
  type: "string",
160
149
  description:
161
- "Delivery target. For Telegram, this is the chat ID. For Slack, the channel ID.",
150
+ "Optional. Delivery target. For Telegram, this is the chat ID. For Slack, the channel ID. When omitted, the plugin infers it from the active channel config.",
162
151
  },
163
152
  chat_channel: {
164
153
  type: "string",
@@ -172,15 +161,35 @@ export default function register(api: any) {
172
161
  "User's preferred city for tour notifications (e.g. 'san-francisco', 'new-york', 'london'). When set, tour notifications highlight shows in this city.",
173
162
  },
174
163
  },
175
- required: ["artists", "frequency", "chat_target"],
164
+ required: ["artists"],
176
165
  },
177
166
  async execute(
178
167
  _id: string,
179
- params: { artists: string[]; frequency: string; chat_target: string; chat_channel?: string; city?: string },
168
+ params: {
169
+ artists: string[];
170
+ frequency?: string;
171
+ chat_target?: string;
172
+ chat_channel?: string;
173
+ city?: string;
174
+ },
180
175
  ) {
181
176
  try {
182
177
  const config = cfg();
183
178
  let apiKey = config.apiKey;
179
+ const frequency = params.frequency ?? config.frequency ?? "periodic";
180
+ const chatTarget = params.chat_target ?? inferChatTarget();
181
+ const chatChannel = params.chat_channel ?? inferChatChannel();
182
+
183
+ if (!chatTarget) {
184
+ return {
185
+ content: [
186
+ {
187
+ type: "text",
188
+ text: "Setup needs a delivery target. Message me from Telegram or Slack first, or pass chat_target explicitly.",
189
+ },
190
+ ],
191
+ };
192
+ }
184
193
 
185
194
  if (!apiKey) {
186
195
  const agentId =
@@ -194,10 +203,10 @@ export default function register(api: any) {
194
203
  persistConfig({
195
204
  apiKey,
196
205
  artists: params.artists,
197
- frequency: params.frequency,
198
- chatTarget: params.chat_target,
199
- chatChannel: params.chat_channel ?? "telegram",
200
- city: params.city ?? "",
206
+ frequency,
207
+ chatTarget,
208
+ chatChannel,
209
+ city: params.city ?? config.city ?? "",
201
210
  });
202
211
 
203
212
  if (params.city) {
@@ -220,6 +229,7 @@ export default function register(api: any) {
220
229
 
221
230
  pollService.start();
222
231
  results.push("Notification polling enabled");
232
+ results.push(`Delivery: ${chatChannel}:${chatTarget}`);
223
233
 
224
234
  return {
225
235
  content: [{ type: "text", text: results.join("\n") }],
@@ -0,0 +1,159 @@
1
+ import { readFileSync, writeFileSync } from "fs";
2
+ import { homedir } from "os";
3
+ import { join } from "path";
4
+
5
+ const LEGACY_PLUGIN_IDS = ["twindex"];
6
+
7
+ export type NewLorePluginConfig = {
8
+ apiKey?: string;
9
+ artists?: string[];
10
+ frequency?: string;
11
+ chatTarget?: string;
12
+ chatChannel?: string;
13
+ city?: string;
14
+ };
15
+
16
+ function isRecord(value: unknown): value is Record<string, any> {
17
+ return typeof value === "object" && value !== null;
18
+ }
19
+
20
+ function resolveLegacyConfig(api: any): NewLorePluginConfig | undefined {
21
+ const entries = api?.config?.plugins?.entries;
22
+ if (!isRecord(entries)) return undefined;
23
+ for (const pluginId of LEGACY_PLUGIN_IDS) {
24
+ const legacyConfig = entries[pluginId]?.config;
25
+ if (isRecord(legacyConfig) && Object.keys(legacyConfig).length > 0) {
26
+ return legacyConfig as NewLorePluginConfig;
27
+ }
28
+ }
29
+ return undefined;
30
+ }
31
+
32
+ function ensurePluginEntry(api: any, pluginId: string): Record<string, any> {
33
+ if (!isRecord(api.config)) api.config = {};
34
+ if (!isRecord(api.config.plugins)) api.config.plugins = {};
35
+ if (!isRecord(api.config.plugins.entries)) api.config.plugins.entries = {};
36
+ if (!isRecord(api.config.plugins.entries[pluginId])) {
37
+ api.config.plugins.entries[pluginId] = {};
38
+ }
39
+ return api.config.plugins.entries[pluginId];
40
+ }
41
+
42
+ function pruneLegacyPluginState(target: any, pluginId: string): boolean {
43
+ if (!isRecord(target?.plugins) || LEGACY_PLUGIN_IDS.includes(pluginId)) {
44
+ return false;
45
+ }
46
+
47
+ let changed = false;
48
+
49
+ if (isRecord(target.plugins.entries)) {
50
+ for (const legacyPluginId of LEGACY_PLUGIN_IDS) {
51
+ if (legacyPluginId !== pluginId && legacyPluginId in target.plugins.entries) {
52
+ delete target.plugins.entries[legacyPluginId];
53
+ changed = true;
54
+ }
55
+ }
56
+ }
57
+
58
+ if (isRecord(target.plugins.installs)) {
59
+ for (const legacyPluginId of LEGACY_PLUGIN_IDS) {
60
+ if (legacyPluginId !== pluginId && legacyPluginId in target.plugins.installs) {
61
+ delete target.plugins.installs[legacyPluginId];
62
+ changed = true;
63
+ }
64
+ }
65
+ }
66
+
67
+ return changed;
68
+ }
69
+
70
+ function persistConfigToDisk(
71
+ pluginId: string,
72
+ nextConfig?: NewLorePluginConfig,
73
+ ): boolean {
74
+ const configPath = join(homedir(), ".openclaw", "openclaw.json");
75
+ const raw = readFileSync(configPath, "utf-8");
76
+ const disk = JSON.parse(raw);
77
+ let changed = false;
78
+
79
+ if (nextConfig) {
80
+ if (!isRecord(disk.plugins)) disk.plugins = {};
81
+ if (!isRecord(disk.plugins.entries)) disk.plugins.entries = {};
82
+ if (!isRecord(disk.plugins.entries[pluginId])) disk.plugins.entries[pluginId] = {};
83
+ disk.plugins.entries[pluginId].config = nextConfig;
84
+ changed = true;
85
+ }
86
+
87
+ changed = pruneLegacyPluginState(disk, pluginId) || changed;
88
+
89
+ if (changed) {
90
+ writeFileSync(configPath, JSON.stringify(disk, null, 2) + "\n", "utf-8");
91
+ }
92
+
93
+ return changed;
94
+ }
95
+
96
+ export function getPluginId(api: any): string {
97
+ if (typeof api?.id === "string" && api.id.trim()) return api.id.trim();
98
+ return LEGACY_PLUGIN_IDS[0];
99
+ }
100
+
101
+ export function getPluginConfig(api: any): NewLorePluginConfig {
102
+ if (isRecord(api?.pluginConfig) && Object.keys(api.pluginConfig).length > 0) {
103
+ return api.pluginConfig as NewLorePluginConfig;
104
+ }
105
+
106
+ const pluginId = getPluginId(api);
107
+ const currentConfig = api?.config?.plugins?.entries?.[pluginId]?.config;
108
+ if (isRecord(currentConfig) && Object.keys(currentConfig).length > 0) {
109
+ return currentConfig as NewLorePluginConfig;
110
+ }
111
+
112
+ return resolveLegacyConfig(api) ?? {};
113
+ }
114
+
115
+ export function persistPluginConfig(
116
+ api: any,
117
+ updates: Record<string, any>,
118
+ ): NewLorePluginConfig {
119
+ const pluginId = getPluginId(api);
120
+ const nextConfig = {
121
+ ...getPluginConfig(api),
122
+ ...updates,
123
+ };
124
+
125
+ ensurePluginEntry(api, pluginId).config = nextConfig;
126
+ pruneLegacyPluginState(api.config, pluginId);
127
+ api.pluginConfig = nextConfig;
128
+
129
+ // OpenClaw does not currently expose a config.save() helper to plugins.
130
+ try {
131
+ persistConfigToDisk(pluginId, nextConfig);
132
+ } catch (err: any) {
133
+ api.logger?.warn?.(`NewLore: failed to persist config: ${err.message}`);
134
+ }
135
+
136
+ return nextConfig;
137
+ }
138
+
139
+ export function migrateLegacyPluginConfig(api: any): boolean {
140
+ const pluginId = getPluginId(api);
141
+ if (LEGACY_PLUGIN_IDS.includes(pluginId)) return false;
142
+
143
+ const currentConfig = api?.config?.plugins?.entries?.[pluginId]?.config;
144
+ if (isRecord(currentConfig) && Object.keys(currentConfig).length > 0) {
145
+ api.pluginConfig = currentConfig;
146
+ try {
147
+ return pruneLegacyPluginState(api.config, pluginId) || persistConfigToDisk(pluginId);
148
+ } catch (err: any) {
149
+ api.logger?.warn?.(`NewLore: failed to prune legacy config: ${err.message}`);
150
+ return false;
151
+ }
152
+ }
153
+
154
+ const legacyConfig = resolveLegacyConfig(api);
155
+ if (!legacyConfig) return false;
156
+
157
+ persistPluginConfig(api, legacyConfig);
158
+ return true;
159
+ }
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { execFile } from "child_process";
7
7
  import * as twindex from "./client.js";
8
- import { mergedPluginConfig } from "./config.js";
8
+ import { getPluginConfig, getPluginId } from "./plugin-config.js";
9
9
 
10
10
  const FREQUENCY_MS: Record<string, number> = {
11
11
  realtime: 20 * 1000, // 20 sec (demo speed)
@@ -13,43 +13,25 @@ const FREQUENCY_MS: Record<string, number> = {
13
13
  daily: 24 * 60 * 60 * 1000, // 24 hours
14
14
  };
15
15
 
16
- const INITIAL_POLL_DELAY_MS = 15 * 1000;
17
- const MESSAGE_SEND_TIMEOUT_MS = 30 * 1000;
18
-
19
16
  function sendMessage(
20
17
  message: string,
21
18
  channel: string,
22
19
  target: string,
23
- ): Promise<{ ok: boolean; reason?: string }> {
20
+ ): Promise<boolean> {
24
21
  return new Promise((resolve) => {
25
22
  execFile(
26
23
  "openclaw",
27
24
  ["message", "send", "--channel", channel, "--target", target, "-m", message],
28
- { timeout: MESSAGE_SEND_TIMEOUT_MS },
29
- (err, stdout, stderr) => {
30
- if (!err) {
31
- resolve({ ok: true });
32
- return;
33
- }
34
-
35
- const details = [err.message, stderr?.trim(), stdout?.trim()]
36
- .filter(Boolean)
37
- .join(" | ");
38
- resolve({ ok: false, reason: details || "unknown delivery error" });
39
- },
25
+ { timeout: 30_000 },
26
+ (err) => resolve(!err),
40
27
  );
41
28
  });
42
29
  }
43
30
 
44
31
  export function createNotificationService(api: any) {
45
32
  let timer: ReturnType<typeof setInterval> | null = null;
46
- let startupPoll: ReturnType<typeof setTimeout> | null = null;
47
- let running = false;
48
33
  let polling = false; // guard against overlapping polls
49
-
50
- function cfg(): Record<string, any> {
51
- return mergedPluginConfig(api);
52
- }
34
+ const cfg = () => getPluginConfig(api);
53
35
 
54
36
  function getApiKey(): string | undefined {
55
37
  return cfg().apiKey;
@@ -157,17 +139,15 @@ export function createNotificationService(api: any) {
157
139
  message += `\n\nWant to see how it looks on you? Send me a selfie and I'll show you.`;
158
140
  }
159
141
 
160
- const delivery = await sendMessage(message, channel, target);
161
- if (delivery.ok) {
142
+ const delivered = await sendMessage(message, channel, target);
143
+ if (delivered) {
162
144
  try {
163
145
  await twindex.markRead(apiKey, [notif.id]);
164
146
  } catch {
165
147
  // Non-fatal — delivered but read status will catch up
166
148
  }
167
149
  } else {
168
- logger?.warn?.(
169
- `Twindex: message delivery failed, will retry next poll (${delivery.reason})`,
170
- );
150
+ logger?.warn?.("Twindex: message delivery failed, will retry next poll");
171
151
  break; // Don't pile up failed deliveries
172
152
  }
173
153
  }
@@ -179,7 +159,7 @@ export function createNotificationService(api: any) {
179
159
  }
180
160
 
181
161
  return {
182
- id: "twindex-notifications",
162
+ id: `${getPluginId(api)}-notifications`,
183
163
 
184
164
  start() {
185
165
  // Clear any stale timer from a previous gateway reload
@@ -187,10 +167,6 @@ export function createNotificationService(api: any) {
187
167
  clearInterval(timer);
188
168
  timer = null;
189
169
  }
190
- if (startupPoll) {
191
- clearTimeout(startupPoll);
192
- startupPoll = null;
193
- }
194
170
 
195
171
  const apiKey = getApiKey();
196
172
  if (!apiKey) {
@@ -198,31 +174,21 @@ export function createNotificationService(api: any) {
198
174
  return;
199
175
  }
200
176
 
201
- running = true;
202
177
  const freq = getFrequency();
203
178
  const intervalMs = FREQUENCY_MS[freq] ?? FREQUENCY_MS.periodic;
204
179
 
180
+ // Immediate first poll
181
+ poll();
182
+
205
183
  timer = setInterval(poll, intervalMs);
206
- const initialDelay = Math.min(INITIAL_POLL_DELAY_MS, intervalMs);
207
- startupPoll = setTimeout(() => {
208
- startupPoll = null;
209
- poll();
210
- }, initialDelay);
211
- logger?.info?.(
212
- `Twindex: poll service started (${freq}, every ${intervalMs / 1000}s, initial delay ${initialDelay / 1000}s)`,
213
- );
184
+ logger?.info?.(`Twindex: poll service started (${freq}, every ${intervalMs / 1000}s)`);
214
185
  },
215
186
 
216
187
  stop() {
217
- running = false;
218
188
  if (timer) {
219
189
  clearInterval(timer);
220
190
  timer = null;
221
191
  }
222
- if (startupPoll) {
223
- clearTimeout(startupPoll);
224
- startupPoll = null;
225
- }
226
192
  logger?.info?.("Twindex: poll service stopped");
227
193
  },
228
194
  };
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { execFile } from "child_process";
6
6
  import * as twindex from "./client.js";
7
+ import { getPluginConfig, getPluginId } from "./plugin-config.js";
7
8
 
8
9
  const BASE_URL = "https://newlore.ai";
9
10
  const BACKOFF_STEPS = [1000, 2000, 5000, 10_000, 30_000, 60_000];
@@ -33,9 +34,10 @@ export function createNotificationService(api: any) {
33
34
  let retryCount = 0;
34
35
  let lastEventId: string | null = null;
35
36
  let running = false;
37
+ const cfg = () => getPluginConfig(api);
36
38
 
37
39
  function getApiKey(): string | undefined {
38
- return api.config?.plugins?.entries?.twindex?.config?.apiKey;
40
+ return cfg().apiKey;
39
41
  }
40
42
 
41
43
  const logger = api.logger;
@@ -146,7 +148,7 @@ export function createNotificationService(api: any) {
146
148
  }
147
149
 
148
150
  return {
149
- id: "twindex-notifications",
151
+ id: `${getPluginId(api)}-notifications`,
150
152
 
151
153
  start() {
152
154
  if (running) return;
package/src/config.ts DELETED
@@ -1,23 +0,0 @@
1
- import { existsSync, readFileSync } from "fs";
2
- import { homedir } from "os";
3
- import { join } from "path";
4
-
5
- type PluginConfig = Record<string, any>;
6
-
7
- export function readDiskPluginConfig(): PluginConfig {
8
- try {
9
- const configPath = join(homedir(), ".openclaw", "openclaw.json");
10
- if (!existsSync(configPath)) return {};
11
- const raw = readFileSync(configPath, "utf-8");
12
- const parsed = JSON.parse(raw);
13
- return parsed?.plugins?.entries?.twindex?.config ?? {};
14
- } catch {
15
- return {};
16
- }
17
- }
18
-
19
- export function mergedPluginConfig(api: any): PluginConfig {
20
- const disk = readDiskPluginConfig();
21
- const memory = api.config?.plugins?.entries?.twindex?.config ?? {};
22
- return { ...disk, ...memory };
23
- }