ai-control-center 1.15.2
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/LICENSE +21 -0
- package/README.md +584 -0
- package/bin/aicc.js +772 -0
- package/lib/actions/approve.js +71 -0
- package/lib/actions/assign-project.js +132 -0
- package/lib/actions/browser-test.js +64 -0
- package/lib/actions/cleanup.js +174 -0
- package/lib/actions/debug.js +298 -0
- package/lib/actions/deploy.js +1229 -0
- package/lib/actions/fix-bug.js +134 -0
- package/lib/actions/new-feature.js +255 -0
- package/lib/actions/reject.js +307 -0
- package/lib/actions/review.js +706 -0
- package/lib/actions/status.js +47 -0
- package/lib/agents/browser-qa-agent.js +611 -0
- package/lib/agents/payment-agent.js +116 -0
- package/lib/agents/suggestion-agent.js +88 -0
- package/lib/cli.js +303 -0
- package/lib/config.js +243 -0
- package/lib/hub/hub-server.js +440 -0
- package/lib/hub/project-poller.js +75 -0
- package/lib/hub/skill-registry.js +89 -0
- package/lib/hub/state-aggregator.js +204 -0
- package/lib/index.js +471 -0
- package/lib/init/doctor.js +523 -0
- package/lib/init/presets.js +222 -0
- package/lib/init/skill-fetcher.js +77 -0
- package/lib/init/wizard.js +973 -0
- package/lib/integrations/codex-runner.js +128 -0
- package/lib/integrations/github-actions.js +248 -0
- package/lib/integrations/github-reporter.js +229 -0
- package/lib/integrations/screenshot-store.js +102 -0
- package/lib/openclaw/bridge.js +650 -0
- package/lib/openclaw/generate-skill.js +235 -0
- package/lib/openclaw/openclaw.json +64 -0
- package/lib/orchestrator/autonomous-loop.js +429 -0
- package/lib/orchestrator/thread-triggers.js +63 -0
- package/lib/roleplay/agent-messenger.js +75 -0
- package/lib/roleplay/discussion-threads.js +303 -0
- package/lib/roleplay/health-monitor.js +121 -0
- package/lib/roleplay/pm-agent.js +513 -0
- package/lib/roleplay/roleplay-config.js +25 -0
- package/lib/roleplay/room.js +164 -0
- package/lib/shared/action-runner.js +2330 -0
- package/lib/shared/event-bus.js +185 -0
- package/lib/slack/bot.js +378 -0
- package/lib/telegram/bot.js +416 -0
- package/lib/telegram/commands.js +1267 -0
- package/lib/telegram/keyboards.js +113 -0
- package/lib/telegram/notifications.js +247 -0
- package/lib/twitch/bot.js +354 -0
- package/lib/twitch/commands.js +302 -0
- package/lib/twitch/notifications.js +63 -0
- package/lib/utils/achievements.js +191 -0
- package/lib/utils/activity-log.js +182 -0
- package/lib/utils/agent-leaderboard.js +119 -0
- package/lib/utils/audit-logger.js +232 -0
- package/lib/utils/codebase-context.js +288 -0
- package/lib/utils/codebase-indexer.js +381 -0
- package/lib/utils/config-schema.js +230 -0
- package/lib/utils/context-compressor.js +172 -0
- package/lib/utils/correlation.js +63 -0
- package/lib/utils/cost-tracker.js +423 -0
- package/lib/utils/cron-scheduler.js +53 -0
- package/lib/utils/db-adapter.js +293 -0
- package/lib/utils/display.js +272 -0
- package/lib/utils/errors.js +116 -0
- package/lib/utils/format.js +134 -0
- package/lib/utils/intent-engine.js +464 -0
- package/lib/utils/mcp-client.js +238 -0
- package/lib/utils/model-ab-test.js +164 -0
- package/lib/utils/notify.js +122 -0
- package/lib/utils/persona-loader.js +80 -0
- package/lib/utils/pipeline-lock.js +73 -0
- package/lib/utils/pipeline.js +214 -0
- package/lib/utils/plugin-runner.js +234 -0
- package/lib/utils/rate-limiter.js +84 -0
- package/lib/utils/rbac.js +74 -0
- package/lib/utils/runner.js +1809 -0
- package/lib/utils/security.js +191 -0
- package/lib/utils/self-healer.js +144 -0
- package/lib/utils/skill-loader.js +255 -0
- package/lib/utils/spinner.js +132 -0
- package/lib/utils/stage-queue.js +50 -0
- package/lib/utils/state-machine.js +89 -0
- package/lib/utils/status-bar.js +327 -0
- package/lib/utils/token-estimator.js +101 -0
- package/lib/utils/ux-analyzer.js +101 -0
- package/lib/utils/webhook-emitter.js +83 -0
- package/lib/web/public/css/styles.css +417 -0
- package/lib/web/public/dark-mode.js +44 -0
- package/lib/web/public/hub/kanban.html +206 -0
- package/lib/web/public/index.html +45 -0
- package/lib/web/public/js/app.js +71 -0
- package/lib/web/public/js/ask.js +110 -0
- package/lib/web/public/js/dashboard.js +165 -0
- package/lib/web/public/js/deploy.js +72 -0
- package/lib/web/public/js/feature.js +79 -0
- package/lib/web/public/js/health.js +65 -0
- package/lib/web/public/js/logs.js +93 -0
- package/lib/web/public/js/review.js +123 -0
- package/lib/web/public/js/ws-client.js +82 -0
- package/lib/web/public/office/css/office.css +678 -0
- package/lib/web/public/office/index.html +148 -0
- package/lib/web/public/office/js/achievements-ui.js +117 -0
- package/lib/web/public/office/js/character.js +1056 -0
- package/lib/web/public/office/js/chat-bubbles.js +177 -0
- package/lib/web/public/office/js/cost-overlay.js +123 -0
- package/lib/web/public/office/js/day-night.js +68 -0
- package/lib/web/public/office/js/effects.js +632 -0
- package/lib/web/public/office/js/engine.js +146 -0
- package/lib/web/public/office/js/feature-ticket.js +216 -0
- package/lib/web/public/office/js/hub-client.js +60 -0
- package/lib/web/public/office/js/main.js +1757 -0
- package/lib/web/public/office/js/office-layout.js +1524 -0
- package/lib/web/public/office/js/pathfinding.js +144 -0
- package/lib/web/public/office/js/pixel-sprites.js +1454 -0
- package/lib/web/public/office/js/progress-bars.js +117 -0
- package/lib/web/public/office/js/replay.js +191 -0
- package/lib/web/public/office/js/sound-effects.js +91 -0
- package/lib/web/public/office/js/sprite-renderer.js +211 -0
- package/lib/web/public/office/js/stamina-system.js +89 -0
- package/lib/web/public/office/js/ui.js +107 -0
- package/lib/web/public/onboarding/index.html +243 -0
- package/lib/web/public/timeline/index.html +195 -0
- package/lib/web/routes/api.js +499 -0
- package/lib/web/routes/logs.js +20 -0
- package/lib/web/routes/metrics.js +99 -0
- package/lib/web/server.js +183 -0
- package/lib/web/ws/handler.js +65 -0
- package/package.json +67 -0
- package/templates/agent-architect.md +69 -0
- package/templates/agent-gemini-pm.md +49 -0
- package/templates/agent-gemini-reviewer.md +52 -0
- package/templates/copilot-instructions.md +36 -0
- package/templates/pipelines/mobile.json +27 -0
- package/templates/pipelines/nodejs-api.json +27 -0
- package/templates/pipelines/python.json +27 -0
- package/templates/pipelines/react.json +27 -0
- package/templates/pipelines/salesforce.json +27 -0
- package/templates/role-gemini.md +97 -0
- package/templates/skill-architect.md +114 -0
- package/templates/skill-browser-qa.md +50 -0
- package/templates/skill-bug-from-qa.md +58 -0
- package/templates/skill-chatbot.md +93 -0
- package/templates/skill-implement.md +78 -0
- package/templates/skill-openclaw.md +174 -0
- package/templates/skill-payment.md +110 -0
- package/templates/skill-pm-spec.md +77 -0
- package/templates/skill-requirement-capture.md +97 -0
- package/templates/skill-review.md +108 -0
- package/templates/skill-reviewer-qa.md +44 -0
- package/templates/skill-suggestion.md +45 -0
- package/templates/skill-template.md +142 -0
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI Control Center Twitch Bot — remote pipeline control + notifications.
|
|
3
|
+
*
|
|
4
|
+
* Setup:
|
|
5
|
+
* 1. Create a Twitch application at https://dev.twitch.tv/console
|
|
6
|
+
* 2. Generate an OAuth access token (chat:read, chat:edit scopes)
|
|
7
|
+
* 3. Set env vars (prefix with your project prefix from aicc.config.js):
|
|
8
|
+
* export {PREFIX}_TWITCH_CHANNEL=your_channel_name
|
|
9
|
+
* export {PREFIX}_TWITCH_ACCESS_TOKEN=your_oauth_token
|
|
10
|
+
* export {PREFIX}_TWITCH_CLIENT_ID=your_client_id
|
|
11
|
+
* export {PREFIX}_TWITCH_CLIENT_SECRET=your_client_secret (optional, for token refresh)
|
|
12
|
+
* export {PREFIX}_TWITCH_ALLOWED_IDS=id1,id2 (Twitch user IDs — comma-separated)
|
|
13
|
+
* 4. Run: aicc twitch
|
|
14
|
+
*
|
|
15
|
+
* Commands (use ! prefix in chat, e.g. !status):
|
|
16
|
+
* !status — Pipeline status
|
|
17
|
+
* !feature — Start a new feature
|
|
18
|
+
* !review — Run code review
|
|
19
|
+
* !deploy — Deploy
|
|
20
|
+
* !approve — Approve current feature
|
|
21
|
+
* !reject — Reject / request fixes
|
|
22
|
+
* !logs — Show recent logs
|
|
23
|
+
* !health — System health check
|
|
24
|
+
* !ai — Ask the AI a question
|
|
25
|
+
*
|
|
26
|
+
* Security:
|
|
27
|
+
* - TWITCH_ALLOWED_IDS restricts which Twitch user IDs can use commands.
|
|
28
|
+
* - Commands from unauthorized users are silently ignored.
|
|
29
|
+
*
|
|
30
|
+
* Rate Limiting:
|
|
31
|
+
* - Twitch allows 20 messages per 30 seconds for non-moderator bots.
|
|
32
|
+
* - This bot enforces a queue to stay within limits.
|
|
33
|
+
*/
|
|
34
|
+
import https from 'https';
|
|
35
|
+
import { WebSocket } from 'ws';
|
|
36
|
+
import { env, getConfig, loadConfig } from '../config.js';
|
|
37
|
+
import { autoResumePipeline } from '../shared/action-runner.js';
|
|
38
|
+
import { bus } from '../shared/event-bus.js';
|
|
39
|
+
import { registerCommands } from './commands.js';
|
|
40
|
+
import { setupNotifications } from './notifications.js';
|
|
41
|
+
|
|
42
|
+
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
const IRC_URL = 'wss://irc-ws.chat.twitch.tv:443';
|
|
45
|
+
const MAX_MSG_LENGTH = 500;
|
|
46
|
+
const RATE_LIMIT_MSGS = 20;
|
|
47
|
+
const RATE_LIMIT_MS = 30000;
|
|
48
|
+
|
|
49
|
+
// ─── Message truncation ───────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
export function truncate(text) {
|
|
52
|
+
if (!text) return '';
|
|
53
|
+
const flat = text.replace(/\n+/g, ' ').trim();
|
|
54
|
+
return flat.length <= MAX_MSG_LENGTH ? flat : flat.slice(0, MAX_MSG_LENGTH - 3) + '...';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ─── Validate OAuth token → get bot username ──────────────────────────────────
|
|
58
|
+
|
|
59
|
+
async function getBotUsername(accessToken) {
|
|
60
|
+
return new Promise((resolve) => {
|
|
61
|
+
const options = {
|
|
62
|
+
hostname: 'id.twitch.tv',
|
|
63
|
+
path: '/oauth2/validate',
|
|
64
|
+
method: 'GET',
|
|
65
|
+
headers: { Authorization: `OAuth ${accessToken}` },
|
|
66
|
+
};
|
|
67
|
+
const req = https.request(options, (res) => {
|
|
68
|
+
let body = '';
|
|
69
|
+
res.on('data', d => { body += d; });
|
|
70
|
+
res.on('end', () => {
|
|
71
|
+
try {
|
|
72
|
+
const json = JSON.parse(body);
|
|
73
|
+
resolve(json.login || null);
|
|
74
|
+
} catch {
|
|
75
|
+
resolve(null);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
req.on('error', () => resolve(null));
|
|
80
|
+
req.end();
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ─── Bot class ────────────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
class TwitchBot {
|
|
87
|
+
constructor({ channel, accessToken, allowedIds, botUsername }) {
|
|
88
|
+
this.channel = channel.toLowerCase().replace(/^#/, '');
|
|
89
|
+
this.accessToken = accessToken;
|
|
90
|
+
this.allowedIds = allowedIds; // Set of allowed Twitch user IDs
|
|
91
|
+
this.botUsername = botUsername;
|
|
92
|
+
this.ws = null;
|
|
93
|
+
this._connected = false;
|
|
94
|
+
this._reconnectDelay = 5000;
|
|
95
|
+
|
|
96
|
+
// Rate-limit queue: max 20 messages per 30 seconds
|
|
97
|
+
this._msgQueue = [];
|
|
98
|
+
this._msgCount = 0;
|
|
99
|
+
this._rateReset = null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Send a chat message, respecting rate limits */
|
|
103
|
+
say(message) {
|
|
104
|
+
const text = truncate(message);
|
|
105
|
+
if (!text) return;
|
|
106
|
+
this._msgQueue.push(text);
|
|
107
|
+
this._drainQueue();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
_drainQueue() {
|
|
111
|
+
if (!this._connected || this._msgQueue.length === 0) return;
|
|
112
|
+
|
|
113
|
+
if (this._msgCount >= RATE_LIMIT_MSGS) {
|
|
114
|
+
// Queue is full — wait for rate limit window to reset
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const msg = this._msgQueue.shift();
|
|
119
|
+
if (!msg) return;
|
|
120
|
+
|
|
121
|
+
this._send(`PRIVMSG #${this.channel} :${msg}`);
|
|
122
|
+
this._msgCount++;
|
|
123
|
+
|
|
124
|
+
// Schedule rate limit reset
|
|
125
|
+
if (!this._rateReset) {
|
|
126
|
+
this._rateReset = setTimeout(() => {
|
|
127
|
+
this._msgCount = 0;
|
|
128
|
+
this._rateReset = null;
|
|
129
|
+
this._drainQueue(); // Process any queued messages
|
|
130
|
+
}, RATE_LIMIT_MS);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Continue draining if under limit
|
|
134
|
+
if (this._msgCount < RATE_LIMIT_MSGS && this._msgQueue.length > 0) {
|
|
135
|
+
setImmediate(() => this._drainQueue());
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
_send(raw) {
|
|
140
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
141
|
+
this.ws.send(raw);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Connect to Twitch IRC over WebSocket */
|
|
146
|
+
connect() {
|
|
147
|
+
this.ws = new WebSocket(IRC_URL);
|
|
148
|
+
|
|
149
|
+
this.ws.on('open', () => {
|
|
150
|
+
this._connected = false; // not yet authenticated
|
|
151
|
+
// Request tags capability for user-id in messages
|
|
152
|
+
this._send('CAP REQ :twitch.tv/tags');
|
|
153
|
+
this._send(`PASS oauth:${this.accessToken}`);
|
|
154
|
+
this._send(`NICK ${this.botUsername}`);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
this.ws.on('message', (raw) => {
|
|
158
|
+
const line = raw.toString().trim();
|
|
159
|
+
|
|
160
|
+
// Handle PING / PONG
|
|
161
|
+
if (line.startsWith('PING')) {
|
|
162
|
+
this._send('PONG :tmi.twitch.tv');
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Authentication result
|
|
167
|
+
if (line.includes('376') || line.includes('001')) {
|
|
168
|
+
// RPL_ENDOFMOTD or RPL_WELCOME — authenticated
|
|
169
|
+
this._send(`JOIN #${this.channel}`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Joined channel
|
|
173
|
+
if (line.includes(`JOIN #${this.channel}`)) {
|
|
174
|
+
this._connected = true;
|
|
175
|
+
this._reconnectDelay = 5000; // reset backoff on success
|
|
176
|
+
console.log(`\n ${getConfig().name} Twitch Bot`);
|
|
177
|
+
console.log(` @${this.botUsername} → #${this.channel}`);
|
|
178
|
+
if (this.allowedIds.size > 0) {
|
|
179
|
+
console.log(` Allowed IDs: ${[...this.allowedIds].join(', ')}`);
|
|
180
|
+
} else {
|
|
181
|
+
console.log(' WARNING: No allowed IDs configured — all commands are blocked');
|
|
182
|
+
}
|
|
183
|
+
console.log('');
|
|
184
|
+
|
|
185
|
+
// Resume any interrupted pipeline
|
|
186
|
+
setTimeout(() => autoResumePipeline().catch(err => console.error('[Twitch] Resume error:', err.message)), 3000);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Authentication failure
|
|
190
|
+
if (line.includes('NOTICE') && line.includes('Login authentication failed')) {
|
|
191
|
+
console.error('\n [Twitch] Authentication failed — check TWITCH_ACCESS_TOKEN\n');
|
|
192
|
+
this.ws.close();
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// PRIVMSG — incoming chat message
|
|
197
|
+
if (line.includes('PRIVMSG')) {
|
|
198
|
+
this._handleMessage(line);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
this.ws.on('error', (err) => {
|
|
203
|
+
console.error(` [Twitch] WebSocket error: ${err.message}`);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
this.ws.on('close', () => {
|
|
207
|
+
this._connected = false;
|
|
208
|
+
console.log(` [Twitch] Disconnected. Reconnecting in ${this._reconnectDelay / 1000}s...`);
|
|
209
|
+
setTimeout(() => {
|
|
210
|
+
this._reconnectDelay = Math.min(this._reconnectDelay * 2, 60000); // exponential backoff, max 60s
|
|
211
|
+
this.connect();
|
|
212
|
+
}, this._reconnectDelay);
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Parse a PRIVMSG line and dispatch to command or message handler */
|
|
217
|
+
_handleMessage(raw) {
|
|
218
|
+
// Parse IRCv3 tags + message
|
|
219
|
+
// Format: @key=val;key=val :user!user@user.tmi.twitch.tv PRIVMSG #channel :message
|
|
220
|
+
let tags = {};
|
|
221
|
+
let rest = raw;
|
|
222
|
+
|
|
223
|
+
if (raw.startsWith('@')) {
|
|
224
|
+
const spaceIdx = raw.indexOf(' ');
|
|
225
|
+
const tagStr = raw.slice(1, spaceIdx);
|
|
226
|
+
rest = raw.slice(spaceIdx + 1);
|
|
227
|
+
for (const part of tagStr.split(';')) {
|
|
228
|
+
const eqIdx = part.indexOf('=');
|
|
229
|
+
if (eqIdx !== -1) tags[part.slice(0, eqIdx)] = part.slice(eqIdx + 1);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const privmsgMatch = rest.match(/^:(\S+)!\S+ PRIVMSG #\S+ :(.+)$/);
|
|
234
|
+
if (!privmsgMatch) return;
|
|
235
|
+
|
|
236
|
+
const username = privmsgMatch[1];
|
|
237
|
+
const message = privmsgMatch[2].trim();
|
|
238
|
+
const userId = tags['user-id'] || '';
|
|
239
|
+
|
|
240
|
+
// Auth check — enforce TWITCH_ALLOWED_IDS allowlist (applies to both ! commands and NL)
|
|
241
|
+
if (this.allowedIds.size > 0 && !this.allowedIds.has(userId) && !this.allowedIds.has(username)) {
|
|
242
|
+
// Log the blocked attempt for audit, but do NOT respond in chat
|
|
243
|
+
console.log(` [Twitch] BLOCKED: ${username} (user-id: ${userId || 'unknown'}) — not in TWITCH_ALLOWED_IDS`);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (message.startsWith('!')) {
|
|
248
|
+
// Explicit bot command — ! prefix
|
|
249
|
+
console.log(` [Twitch] Command from ${username} (${userId}): ${message}`);
|
|
250
|
+
const [cmdRaw, ...argParts] = message.slice(1).split(' ');
|
|
251
|
+
const cmd = cmdRaw.toLowerCase();
|
|
252
|
+
const args = argParts.join(' ').trim();
|
|
253
|
+
this._dispatch(cmd, args, username);
|
|
254
|
+
} else if (this._messageHandler) {
|
|
255
|
+
// Natural language — pass to NL handler if registered
|
|
256
|
+
console.log(` [Twitch] Message from ${username} (${userId}): ${message.slice(0, 80)}`);
|
|
257
|
+
this._messageHandler(message, username);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
_dispatch(cmd, args, username) {
|
|
262
|
+
if (this._commandHandler) {
|
|
263
|
+
this._commandHandler(cmd, args, username, this);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
onCommand(handler) {
|
|
268
|
+
this._commandHandler = handler;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Register a handler for non-! natural language messages from authorized users */
|
|
272
|
+
onMessage(handler) {
|
|
273
|
+
this._messageHandler = handler;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
disconnect() {
|
|
277
|
+
this._connected = false;
|
|
278
|
+
if (this.ws) {
|
|
279
|
+
this.ws.removeAllListeners('close'); // prevent auto-reconnect on intentional close
|
|
280
|
+
this.ws.close();
|
|
281
|
+
}
|
|
282
|
+
if (this._rateReset) {
|
|
283
|
+
clearTimeout(this._rateReset);
|
|
284
|
+
this._rateReset = null;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ─── Start bot ────────────────────────────────────────────────────────────────
|
|
290
|
+
|
|
291
|
+
export async function startBot() {
|
|
292
|
+
// Guard against double execution
|
|
293
|
+
const GUARD_KEY = '__aicc_twitch_bot_started__';
|
|
294
|
+
if (globalThis[GUARD_KEY]) return;
|
|
295
|
+
globalThis[GUARD_KEY] = true;
|
|
296
|
+
|
|
297
|
+
await loadConfig().catch(() => {});
|
|
298
|
+
|
|
299
|
+
const prefix = (() => { try { return getConfig().envPrefix; } catch { return 'AICC'; } })();
|
|
300
|
+
|
|
301
|
+
const CHANNEL = env('TWITCH_CHANNEL');
|
|
302
|
+
const ACCESS_TOKEN = env('TWITCH_ACCESS_TOKEN');
|
|
303
|
+
const ALLOWED_RAW = env('TWITCH_ALLOWED_IDS');
|
|
304
|
+
|
|
305
|
+
if (!CHANNEL) {
|
|
306
|
+
console.error(`\n Missing ${prefix}_TWITCH_CHANNEL environment variable.\n`);
|
|
307
|
+
process.exit(1);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (!ACCESS_TOKEN) {
|
|
311
|
+
console.error(`\n Missing ${prefix}_TWITCH_ACCESS_TOKEN environment variable.`);
|
|
312
|
+
console.error(' Generate one at https://dev.twitch.tv/console with chat:read and chat:edit scopes.\n');
|
|
313
|
+
process.exit(1);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Validate token and get bot username
|
|
317
|
+
const botUsername = await getBotUsername(ACCESS_TOKEN);
|
|
318
|
+
if (!botUsername) {
|
|
319
|
+
console.error('\n Failed to validate TWITCH_ACCESS_TOKEN. Check the token is valid and not expired.\n');
|
|
320
|
+
process.exit(1);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Build allowed user ID set
|
|
324
|
+
const allowedIds = new Set();
|
|
325
|
+
if (ALLOWED_RAW) {
|
|
326
|
+
ALLOWED_RAW.split(',').map(s => s.trim()).filter(Boolean).forEach(id => allowedIds.add(id));
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const bot = new TwitchBot({ channel: CHANNEL, accessToken: ACCESS_TOKEN, allowedIds, botUsername });
|
|
330
|
+
|
|
331
|
+
// Register command handlers
|
|
332
|
+
registerCommands(bot);
|
|
333
|
+
|
|
334
|
+
// Set up pipeline notifications
|
|
335
|
+
setupNotifications(bot, bus);
|
|
336
|
+
|
|
337
|
+
// Start watching status.json
|
|
338
|
+
bus.startWatching();
|
|
339
|
+
|
|
340
|
+
// Connect to Twitch IRC
|
|
341
|
+
bot.connect();
|
|
342
|
+
|
|
343
|
+
// Cleanup on exit
|
|
344
|
+
process.on('SIGINT', () => {
|
|
345
|
+
bus.stopWatching();
|
|
346
|
+
bot.disconnect();
|
|
347
|
+
process.exit(0);
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Auto-start when run directly
|
|
352
|
+
if (process.argv[1]?.endsWith('bot.js')) {
|
|
353
|
+
startBot();
|
|
354
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Twitch bot command handlers — with intent engine parity to Telegram.
|
|
3
|
+
*
|
|
4
|
+
* Two layers of input handling:
|
|
5
|
+
* 1. Explicit ! commands: !status, !feature, !bug, !review, !approve,
|
|
6
|
+
* !reject, !deploy, !logs, !health, !ai,
|
|
7
|
+
* !yes, !no, !intel, !help
|
|
8
|
+
* 2. Natural language: Any non-! message from an authorized user is
|
|
9
|
+
* passed through the shared intent engine, which
|
|
10
|
+
* maps phrases like "deploy now" to the deploy intent.
|
|
11
|
+
*
|
|
12
|
+
* Confirmation flow (Twitch has no inline keyboards):
|
|
13
|
+
* State-changing intents (approve/deploy/reject/fix/implement/fix_and_review)
|
|
14
|
+
* are held in pendingActions[username] and require !yes to execute.
|
|
15
|
+
* Read-only intents (review/status/logs/health) run immediately.
|
|
16
|
+
*
|
|
17
|
+
* Shared modules (same as Telegram):
|
|
18
|
+
* - lib/shared/action-runner.js — all pipeline actions
|
|
19
|
+
* - lib/utils/intent-engine.js — matchIntent, matchLearnedPhrase, learnPhrase
|
|
20
|
+
* - lib/utils/format.js — formatForPlatform
|
|
21
|
+
*/
|
|
22
|
+
import * as actions from '../shared/action-runner.js';
|
|
23
|
+
import { formatForPlatform } from '../utils/format.js';
|
|
24
|
+
import { getIntelStats, learnPhrase, matchIntent, matchLearnedPhrase } from '../utils/intent-engine.js';
|
|
25
|
+
import { truncate } from './bot.js';
|
|
26
|
+
|
|
27
|
+
// Strip markdown and truncate AI output for Twitch plain text
|
|
28
|
+
function formatReply(text) {
|
|
29
|
+
const { text: formatted } = formatForPlatform(
|
|
30
|
+
typeof text === 'object' ? (text?.message || JSON.stringify(text)) : String(text || ''),
|
|
31
|
+
'twitch'
|
|
32
|
+
);
|
|
33
|
+
return truncate(formatted);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ── Per-user pending confirmation state ────────────────────────────────────
|
|
37
|
+
// Shape: { intent, desc, actionFn, rawText }
|
|
38
|
+
const pendingActions = new Map();
|
|
39
|
+
function setPending(username, data) { pendingActions.set(username, data); }
|
|
40
|
+
function getPending(username) { return pendingActions.get(username) || null; }
|
|
41
|
+
function clearPending(username) { pendingActions.delete(username); }
|
|
42
|
+
|
|
43
|
+
// ── Unified action executor ────────────────────────────────────────────────
|
|
44
|
+
// Awaits actionFn(), sends result to chat, and reinforces learning on success.
|
|
45
|
+
async function runAction(bot, actionFn, { successMsg = null, errorMsg = 'Error', rawText = null, intent = null } = {}) {
|
|
46
|
+
try {
|
|
47
|
+
const res = await actionFn();
|
|
48
|
+
const msg = res?.message || res?.summary || successMsg || 'Done.';
|
|
49
|
+
bot.say(truncate(msg));
|
|
50
|
+
if (rawText && intent) learnPhrase(rawText, intent, { trigger: 'twitch_confirmed' });
|
|
51
|
+
} catch (err) {
|
|
52
|
+
bot.say(truncate(`${errorMsg}: ${err.message}`));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── Intent → action mapper (mirrors Telegram) ──────────────────────────────
|
|
57
|
+
// Returns { requiresConfirm, desc, actionFn } or null for unknown intents.
|
|
58
|
+
function buildIntentContext(intent, rawText, statusData) {
|
|
59
|
+
const cleanDesc = rawText
|
|
60
|
+
.replace(/\b(can you|please|could you|let'?s|we should|implement|fix|create|add|build|deploy|review|approve|reject)\b/gi, '')
|
|
61
|
+
.replace(/\s+/g, ' ').trim() || rawText;
|
|
62
|
+
|
|
63
|
+
switch (intent) {
|
|
64
|
+
case 'review':
|
|
65
|
+
return { requiresConfirm: false, desc: 'Run code review', actionFn: () => actions.runReview() };
|
|
66
|
+
case 'approve':
|
|
67
|
+
return { requiresConfirm: true, desc: `Approve current feature (stage: ${statusData?.stage || 'unknown'})`, actionFn: () => actions.runApprove() };
|
|
68
|
+
case 'deploy':
|
|
69
|
+
return { requiresConfirm: true, desc: `Deploy to target (stage: ${statusData?.stage || 'unknown'})`, actionFn: () => actions.runDeploy() };
|
|
70
|
+
case 'fix':
|
|
71
|
+
return { requiresConfirm: true, desc: `Fix bug: "${cleanDesc.slice(0, 80)}"`, actionFn: () => actions.runBugFix(cleanDesc) };
|
|
72
|
+
case 'implement':
|
|
73
|
+
return { requiresConfirm: true, desc: `New feature: "${cleanDesc.slice(0, 80)}"`, actionFn: () => actions.runNewFeature(cleanDesc) };
|
|
74
|
+
case 'fix_and_review':
|
|
75
|
+
return {
|
|
76
|
+
requiresConfirm: true,
|
|
77
|
+
desc: 'Fix bug then run code review',
|
|
78
|
+
actionFn: async () => { await actions.runBugFix(cleanDesc); return actions.runReview(); },
|
|
79
|
+
};
|
|
80
|
+
default:
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── Natural language handler ───────────────────────────────────────────────
|
|
86
|
+
// Called by bot.onMessage() for every non-! message from an authorized user.
|
|
87
|
+
async function handleNaturalLanguage(bot, text, username) {
|
|
88
|
+
const statusData = actions.getStatusData();
|
|
89
|
+
const learned = matchLearnedPhrase(text);
|
|
90
|
+
const intentResult = learned || matchIntent(text, statusData);
|
|
91
|
+
if (!intentResult) return;
|
|
92
|
+
|
|
93
|
+
const { intent, confidence } = intentResult;
|
|
94
|
+
// Higher threshold for Twitch (noisier public chat)
|
|
95
|
+
if (confidence < 0.65) {
|
|
96
|
+
console.log(` [Twitch] NL low confidence (${confidence.toFixed(2)}) for "${text.slice(0, 50)}" — skipped`);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
console.log(` [Twitch] NL intent: ${intent} (${confidence.toFixed(2)}) from ${username}`);
|
|
100
|
+
|
|
101
|
+
const ctx = buildIntentContext(intent, text, statusData);
|
|
102
|
+
if (!ctx) return;
|
|
103
|
+
|
|
104
|
+
if (!ctx.requiresConfirm) {
|
|
105
|
+
bot.say(`→ ${ctx.desc}...`);
|
|
106
|
+
await runAction(bot, ctx.actionFn, { rawText: text, intent });
|
|
107
|
+
} else {
|
|
108
|
+
setPending(username, { intent, desc: ctx.desc, actionFn: ctx.actionFn, rawText: text });
|
|
109
|
+
bot.say(truncate(`${ctx.desc} — reply !yes to confirm or !no to cancel`));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── Command registry ───────────────────────────────────────────────────────
|
|
114
|
+
export function registerCommands(bot) {
|
|
115
|
+
|
|
116
|
+
// Wire natural-language handler for non-! messages
|
|
117
|
+
bot.onMessage(async (text, username) => {
|
|
118
|
+
await handleNaturalLanguage(bot, text, username);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
bot.onCommand(async (cmd, args, username) => {
|
|
122
|
+
switch (cmd) {
|
|
123
|
+
|
|
124
|
+
// ── !help ────────────────────────────────────────────────────────────
|
|
125
|
+
case 'help': {
|
|
126
|
+
bot.say(
|
|
127
|
+
'!status !feature <desc> !bug <desc> !review !approve !reject [reason] ' +
|
|
128
|
+
'!deploy !logs !health !ai <q> !intel — or just chat naturally!'
|
|
129
|
+
);
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── !status ──────────────────────────────────────────────────────────
|
|
134
|
+
case 'status': {
|
|
135
|
+
const s = actions.getStatusData();
|
|
136
|
+
if (!s) { bot.say('No pipeline status available.'); break; }
|
|
137
|
+
bot.say(truncate(`Stage: ${s.stage || 'idle'} | Feature: ${s.current_feature || 'none'} | Mode: ${s.pipeline_mode || 'manual'}`));
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── !feature <desc> ──────────────────────────────────────────────────
|
|
142
|
+
case 'feature': {
|
|
143
|
+
if (!args || !args.trim()) { bot.say('Usage: !feature <description>'); break; }
|
|
144
|
+
bot.say(`Starting feature: ${truncate(args)}...`);
|
|
145
|
+
await runAction(bot, () => actions.runNewFeature(args.trim(), 'manual', 'feature'), {
|
|
146
|
+
successMsg: 'Feature submitted!', errorMsg: 'Feature failed',
|
|
147
|
+
rawText: args.trim(), intent: 'implement',
|
|
148
|
+
});
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── !bug <desc> ──────────────────────────────────────────────────────
|
|
153
|
+
case 'bug': {
|
|
154
|
+
if (!args || !args.trim()) { bot.say('Usage: !bug <description>'); break; }
|
|
155
|
+
bot.say(`Fixing: ${truncate(args)}...`);
|
|
156
|
+
await runAction(bot, () => actions.runBugFix(args.trim()), {
|
|
157
|
+
successMsg: 'Bug fix started!', errorMsg: 'Bug fix failed',
|
|
158
|
+
rawText: args.trim(), intent: 'fix',
|
|
159
|
+
});
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── !review ──────────────────────────────────────────────────────────
|
|
164
|
+
case 'review': {
|
|
165
|
+
bot.say('Running code review...');
|
|
166
|
+
await runAction(bot, () => actions.runReview(), {
|
|
167
|
+
successMsg: 'Review complete.', errorMsg: 'Review failed',
|
|
168
|
+
rawText: '!review', intent: 'review',
|
|
169
|
+
});
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── !deploy ──────────────────────────────────────────────────────────
|
|
174
|
+
case 'deploy': {
|
|
175
|
+
bot.say('Deploying...');
|
|
176
|
+
await runAction(bot, () => actions.runDeploy(), {
|
|
177
|
+
successMsg: 'Deploy successful!', errorMsg: 'Deploy failed',
|
|
178
|
+
rawText: '!deploy', intent: 'deploy',
|
|
179
|
+
});
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ── !approve ─────────────────────────────────────────────────────────
|
|
184
|
+
case 'approve': {
|
|
185
|
+
bot.say('Approving feature...');
|
|
186
|
+
await runAction(bot, () => actions.runApprove(), {
|
|
187
|
+
successMsg: 'Feature approved!', errorMsg: 'Approve failed',
|
|
188
|
+
rawText: '!approve', intent: 'approve',
|
|
189
|
+
});
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ── !reject [reason] ─────────────────────────────────────────────────
|
|
194
|
+
case 'reject': {
|
|
195
|
+
const reason = args && args.trim() ? args.trim() : 'Rejected via Twitch';
|
|
196
|
+
bot.say(`Rejecting: ${truncate(reason)}...`);
|
|
197
|
+
await runAction(bot, () => actions.runReject(reason), {
|
|
198
|
+
successMsg: 'Feature rejected.', errorMsg: 'Reject failed',
|
|
199
|
+
rawText: reason, intent: 'reject',
|
|
200
|
+
});
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── !yes — confirm pending action ────────────────────────────────────
|
|
205
|
+
case 'yes': {
|
|
206
|
+
const pending = getPending(username);
|
|
207
|
+
if (!pending) { bot.say('Nothing pending. Trigger an action via chat or !commands first.'); break; }
|
|
208
|
+
clearPending(username);
|
|
209
|
+
bot.say(`Confirmed: ${truncate(pending.desc)}...`);
|
|
210
|
+
await runAction(bot, pending.actionFn, {
|
|
211
|
+
successMsg: 'Done!', errorMsg: 'Action failed',
|
|
212
|
+
rawText: pending.rawText, intent: pending.intent,
|
|
213
|
+
});
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ── !no — cancel pending action ──────────────────────────────────────
|
|
218
|
+
case 'no': {
|
|
219
|
+
const pending = getPending(username);
|
|
220
|
+
if (!pending) { bot.say('Nothing pending to cancel.'); break; }
|
|
221
|
+
clearPending(username);
|
|
222
|
+
bot.say('Cancelled.');
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ── !logs ────────────────────────────────────────────────────────────
|
|
227
|
+
case 'logs': {
|
|
228
|
+
const logData = actions.getLogsData(5);
|
|
229
|
+
if (!logData || logData.length === 0) { bot.say('No recent logs.'); break; }
|
|
230
|
+
const lines = Array.isArray(logData)
|
|
231
|
+
? logData.slice(-3).join(' | ')
|
|
232
|
+
: String(logData).split('\n').filter(Boolean).slice(-3).join(' | ');
|
|
233
|
+
bot.say(truncate(`Logs: ${lines}`));
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ── !health ──────────────────────────────────────────────────────────
|
|
238
|
+
case 'health': {
|
|
239
|
+
const data = await actions.getHealthData();
|
|
240
|
+
const ok = (v) => v ? '✓' : '✗';
|
|
241
|
+
bot.say(
|
|
242
|
+
`Health — gemini:${ok(data.gemini?.available)} ` +
|
|
243
|
+
`claude:${ok(data.claude?.available)} ` +
|
|
244
|
+
`copilot:${ok(data.copilot?.available)} ` +
|
|
245
|
+
`stage:${data.pipeline?.stage || 'idle'}`
|
|
246
|
+
);
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ── !ai <question> ───────────────────────────────────────────────────
|
|
251
|
+
case 'ai': {
|
|
252
|
+
if (!args || !args.trim()) { bot.say('Usage: !ai <question>'); break; }
|
|
253
|
+
bot.say('Thinking...');
|
|
254
|
+
try {
|
|
255
|
+
const result = await actions.askAI(args.trim());
|
|
256
|
+
if (result?.confirm === true) {
|
|
257
|
+
const desc = result.desc || result.action || 'Proposed action';
|
|
258
|
+
setPending(username, { intent: result.action || 'implement', desc, actionFn: () => actions.runNewFeature(desc), rawText: args.trim() });
|
|
259
|
+
bot.say(truncate(`Ready: "${desc}" — reply !yes to confirm or !no to cancel`));
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
if (result?.pipelineAction) {
|
|
263
|
+
const pa = result.pipelineAction;
|
|
264
|
+
const pipeMap = {
|
|
265
|
+
review: () => actions.runReview(),
|
|
266
|
+
approve: () => actions.runApprove(),
|
|
267
|
+
deploy: () => actions.runDeploy(),
|
|
268
|
+
fix: () => actions.runBugFix(result.desc || args.trim()),
|
|
269
|
+
implement: () => actions.runNewFeature(result.desc || args.trim()),
|
|
270
|
+
fix_and_review: async () => { await actions.runBugFix(result.desc || args.trim()); return actions.runReview(); },
|
|
271
|
+
};
|
|
272
|
+
if (pipeMap[pa]) {
|
|
273
|
+
bot.say(`→ Executing: ${pa}...`);
|
|
274
|
+
await runAction(bot, pipeMap[pa], { successMsg: `${pa} complete.`, errorMsg: `${pa} failed`, rawText: args.trim(), intent: pa });
|
|
275
|
+
} else {
|
|
276
|
+
bot.say(formatReply(result));
|
|
277
|
+
}
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
bot.say(formatReply(result));
|
|
281
|
+
} catch (err) {
|
|
282
|
+
bot.say(truncate(`AI error: ${err.message}`));
|
|
283
|
+
}
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ── !intel — intent engine diagnostics ───────────────────────────────
|
|
288
|
+
case 'intel': {
|
|
289
|
+
try {
|
|
290
|
+
const stats = getIntelStats();
|
|
291
|
+
bot.say(truncate(`Intent engine — patterns: ${stats.patternCount ?? '?'} | learned: ${stats.learnedCount ?? '?'} | confidence: ${stats.avgConfidence ?? '?'}`));
|
|
292
|
+
} catch {
|
|
293
|
+
bot.say('Intent engine stats unavailable.');
|
|
294
|
+
}
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
default:
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
}
|