systa-mcp 1.9.0 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -4
- package/SKILL.md +14 -7
- package/package.json +1 -1
- package/server.js +492 -15
package/README.md
CHANGED
|
@@ -101,7 +101,7 @@ claude mcp add systa \
|
|
|
101
101
|
|
|
102
102
|
## Araçlar (tools)
|
|
103
103
|
|
|
104
|
-
Toplam **
|
|
104
|
+
Toplam **93 araç** vardır; `tools/list` yanıtı anahtarın kapsamına göre filtrelenir
|
|
105
105
|
(bkz. "Scope-aware araç listesi"). Aşağıdakiler en sık kullanılan çekirdek araçlardır —
|
|
106
106
|
tam katalog `list_capabilities` ile keşfedilir.
|
|
107
107
|
|
|
@@ -136,15 +136,20 @@ onu önler. Sıra: **bak → konuş → sahiplen → sinyal ver → bırak.**
|
|
|
136
136
|
| `send_work_session_message` | Birinin açık oturumuna yaz (kapalı oturum 409 döner) — `collaboration.message` |
|
|
137
137
|
| `send_room_message` | Kaydın kendisine kalıcı not: `project` / `request` / `card` — `collaboration.message` |
|
|
138
138
|
| `read_messages` | Konuşmayı oku; `afterId` ile "son okuduğumdan beri ne geldi" |
|
|
139
|
-
| `listen_room` |
|
|
139
|
+
| `listen_room` | Kısa süreli bloke dinleme (varsayılan 30 sn, en çok 240) — `work-session.view-active` |
|
|
140
|
+
| `subscribe_room` | Arka planda dinlemeye başla, ANINDA dön — izleme kipi budur |
|
|
141
|
+
| `drain_room_events` | Aboneliklerde biriken olayları anında al (tamponu boşaltır, API'ye dokunmaz) |
|
|
142
|
+
| `unsubscribe_room` | Aboneliği kapat; tamponda kalan olaylar son kez döner |
|
|
140
143
|
|
|
141
144
|
**Odaya katılma diye bir adım yoktur.** Oda abone olunan kanal değil, kaydın kendisidir;
|
|
142
145
|
erişim kapsamın yetiyorsa okur ve yazarsın. Gantt ve k&k chart üzerindeki düğüm proje,
|
|
143
146
|
talep ya da kart olabilir — mesaj düğümün **temsil ettiği şeyin** odasına gider.
|
|
144
147
|
|
|
145
148
|
**Sürekli dinleme (monitor) kipi yoktur.** MCP istemcisine push gelmez. Bekliyorsan
|
|
146
|
-
`listen_room
|
|
147
|
-
|
|
149
|
+
`listen_room` (en çok 240 sn — daha uzunu MCP istemcileri tarafından kesilir), çalışırken
|
|
150
|
+
haberdar olmak istiyorsan `subscribe_room` + `drain_room_events` (arka planda dinler, hiçbir
|
|
151
|
+
çağrı bloke olmaz, API yoklanmaz), sadece arada bir uğruyorsan uğrayış başına tek
|
|
152
|
+
`read_messages(afterId)`. `listen_room` çağrılarını zincirlemek turu boşa harcar.
|
|
148
153
|
|
|
149
154
|
`systa_api_call` ile, özel bir aracı olmayan herhangi bir endpoint çağrılabilir
|
|
150
155
|
(örn. `POST /requests`, `POST /requests/42/comments`). Anahtarın scope'u dışındaki
|
package/SKILL.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
version: 1.
|
|
2
|
+
version: 1.10.0
|
|
3
3
|
name: systa
|
|
4
4
|
description: |
|
|
5
5
|
Drive SysTa (Talep Takip Sistemi / SysTa) — a
|
|
@@ -234,18 +234,25 @@ Gantt ve k&k chart üzerinde bir düğüm proje, talep ya da kart olabilir. Mesa
|
|
|
234
234
|
|
|
235
235
|
SysTa'nın canlı kanalı bir yayın odasıdır ve ön yüzün dinlediği odanın **aynısıdır**: tarayıcıdaki kullanıcı hangi olayı görüyorsa sen de onu görürsün. Üç şeyi bilmen yeterli:
|
|
236
236
|
|
|
237
|
-
1. **Katılma adımı yoktur.** `listen_room(roomType, roomId)` çağrısı kanalı açar ve abonelik o anda kurulur. Ayrı bir "odaya gir" çağrısı, üyelik kaydı ya da el sıkışma yoktur. Erişim kapsamın o kaydı görmene izin veriyorsa dinleyebilirsin; vermiyorsa çağrı boş sonuç değil **doğrudan ret** döner.
|
|
237
|
+
1. **Katılma adımı yoktur.** `subscribe_room(roomType, roomId)` ya da `listen_room(roomType, roomId)` çağrısı kanalı açar ve abonelik o anda kurulur. Ayrı bir "odaya gir" çağrısı, üyelik kaydı ya da el sıkışma yoktur. Erişim kapsamın o kaydı görmene izin veriyorsa dinleyebilirsin; vermiyorsa çağrı boş sonuç değil **doğrudan ret** döner.
|
|
238
238
|
2. **Olay mesajın KENDİSİNİ taşır.** Gelen olayın gövdesi `read_messages` ile aynı biçimdedir: gönderen, metin, kimlik, hepsi içindedir. Yani "bildirim geldi, şimdi içeriği çekeyim" diye **ikinci bir API çağrısı yapmana gerek yoktur**. Ek çağrı yalnız tek bir durumda gerekir: akış ortasında koparsa, `read_messages(afterId = gördüğün son id)` ile tam kaldığın yerden devam edersin.
|
|
239
239
|
3. **Odada insan olmasa da duyarsın.** Agent kanalı sunucuda bilinçli olarak ayrı tutulur; "odada kimse yok" optimizasyonu senin yayınını kesmez. Gece yarısı tek başına çalışıyorsan da mesaj sana ulaşır.
|
|
240
240
|
|
|
241
|
-
**Yeni mesajı nasıl alırsın:
|
|
241
|
+
**Yeni mesajı nasıl alırsın: üç yol var, hangisinde olduğunu bil.**
|
|
242
242
|
|
|
243
|
-
|
|
243
|
+
Doğru soru şudur: **şu an bekliyor muyum, çalışırken haberdar mı olmak istiyorum, yoksa sadece arada bir uğruyor muyum?** Üçünün aracı ayrıdır ve birini diğerinin yerine kullanmak pahalıdır.
|
|
244
244
|
|
|
245
|
-
1.
|
|
246
|
-
2. **Çalışıyorsan → `read_messages(..., afterId)`.** Yapacak işin var, sadece sen meşgulken ne olduğunu öğrenmek istiyorsun. Her uğrayışta **tek çağrı**, döngü değil. Uğrayışlar arasında asıl işini yap.
|
|
245
|
+
1. **Çalışırken haberdar olmak istiyorsan → `subscribe_room` + `drain_room_events`.** Asıl kip budur ve 1.10.0 ile geldi. `subscribe_room(roomType, roomId)` odanın canlı kanalını **arka planda** açar ve **anında döner** — beklemez, turunu bloke etmez, bir araç çağrısını açık tutmaz. Sen kendi işini yaparsın (dosya düzenler, derleme çalıştırır, kart taşırsın); gelen olaylar bir tamponda birikir. Adımlar arasında `drain_room_events` çağırırsın: o da **anında döner** ve son çağrıdan beri ne geldiyse verir. İş bitince `unsubscribe_room` ya da hiç: abonelikler süreçle birlikte ölür.
|
|
247
246
|
|
|
248
|
-
**
|
|
247
|
+
**Bu yoklamak değildir.** API'ye "yeni bir şey var mı?" diye tekrar tekrar sormuyorsun. Tek bir canlı kanal açık durur ve olayları sana iter; `drain_room_events` yalnız yerel tamponu okur, API'ye hiç dokunmaz. On kez boşaltmak bir kez boşaltmakla aynı maliyettedir.
|
|
248
|
+
|
|
249
|
+
2. **Şu anda bekliyorsan → `listen_room(roomType, roomId, seconds)`.** Bir soru sordun, cevabı gelmeden devam edemiyorsun ve cevabı dakikalar içinde bekliyorsun. Bu çağrı açıkken dosya düzenleyemez, derleme çalıştıramaz, başka araç çağıramazsın. Tavan **240 saniyedir** — ve bu keyfi değil: çağrı açıkken MCP istemcisine **tek bayt akmaz** (kanal canlılık sinyali SysTa ile MCP sunucusu arasındadır, istemci onu görmez), istemci de sessiz çağrıyı askıda sayıp keser. Ölçüldü 2026-09-11: bir Codex istemcisi 3300 saniyelik beklemeyi **altıncı dakikada** `terminated` ile kapattı. Eskiden yazan "en çok bir saat" ifadesi hiçbir istemcide çalışmıyordu. Birkaç dakikadan uzun her şey için 1. maddeyi kullan.
|
|
250
|
+
|
|
251
|
+
3. **Sadece arada bir uğruyorsan → `read_messages(..., afterId)`.** Aboneliğin yok ve geçmişe bakıyorsun. Her uğrayışta **tek çağrı**, döngü değil.
|
|
252
|
+
|
|
253
|
+
**Zincirleme yapma.** `listen_room` çağrılarını arka arkaya dizip odayı "izlemeye almak" turunu boşa harcar ve zaten istemci tarafından kesilir; `read_messages`'ı sıkı döngüde çağırmak da yanlış cevaptır. İzleme kipi artık gerçekten vardır ve adı `subscribe_room`'dur.
|
|
254
|
+
|
|
255
|
+
**Abonelikte kayıp SESSİZ DEĞİLDİR.** Aynı anda en çok 10 oda, oda başına en çok 200 olay tamponlanır. Tampon taşarsa **en eski** olay düşer ve `dropped` bunu sayar. Kanal koparsa kendiliğinden yeniden bağlanır ve `reconnects` artar. Her iki durumda da sonuç `gapSinceLastEventId` taşır: `read_messages(roomType, roomId, afterId = o değer)` ile aralığı tam olarak kapatırsın. `status` aboneliğin nerede olduğunu söyler (`live` | `connecting` | `reconnecting` | `closed`); kapanmışsa `closedReason` sebebini verir (`idleTimeout` — 30 dakika boşaltılmadı, `maxLifetime` — 4 saat doldu, `refused` — erişimin yok, `unsubscribed`, `error`). Yeniden gerekiyorsa yeniden abone ol.
|
|
249
256
|
|
|
250
257
|
**İmleç.** `afterId` = okuduğun son mesajın kimliği; yalnız ondan sonrakileri alırsın. **Zaman damgasını imleç olarak kullanma** — API milisaniye yazar, kolon mikrosaniye tutar ve aynı satır iki kez gelir (ölçüldü 2026-09-08); yanıttaki `pagination.nextAfterId` alanını kullan. İlk çağrıyı imleçsiz yap, yakın geçmişi al, `nextAfterId` değerini sakla ve bundan sonra onu kullan.
|
|
251
258
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "systa-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.0",
|
|
4
4
|
"description": "MCP stdio server for SysTa (Talep Takip Sistemi) — lets AI agents (Claude, Codex) use the SysTa REST API via a scoped API key. Zero npm dependencies (native Node).",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"main": "server.js",
|
package/server.js
CHANGED
|
@@ -1357,6 +1357,250 @@ const MODULE_GATE_NOTE =
|
|
|
1357
1357
|
'NOT an invalid key and NOT a missing permission -- do not tell the user to regenerate the ' +
|
|
1358
1358
|
'key; tell them to have the vendor administrator enable API/MCP access.';
|
|
1359
1359
|
|
|
1360
|
+
// ── Arka plan oda aboneligi (1.10.0) ─────────────────────────────────────────
|
|
1361
|
+
//
|
|
1362
|
+
// NEDEN VAR. `listen_room` bir MCP `tools/call` cagrisini bekleme suresince
|
|
1363
|
+
// ACIK TUTAR. Bekleme boyunca kanaldan istemciye tek bayt akmaz, cunku
|
|
1364
|
+
// keepalive SysTa ile MCP sunucusu arasindaki SSE kanalindadir — istemci onu
|
|
1365
|
+
// GORMEZ. Istemcinin kendi istek zaman asimi devreye girer ve cagriyi
|
|
1366
|
+
// `terminated` ile keser. Olculdu 2026-09-11: Codex CLI, 3300 saniyelik bir
|
|
1367
|
+
// beklemeyi altinci dakikada kesti. Sunucu tarafinda kusur yoktu; istemci
|
|
1368
|
+
// beklemeyi askida kalmis sayiyor.
|
|
1369
|
+
//
|
|
1370
|
+
// COZUM. Beklemeyi cagrinin ICINDEN cikarmak. `subscribe_room` arka planda SSE
|
|
1371
|
+
// baglantisini acar ve ANINDA doner; olaylar bu surecte bir tamponda birikir;
|
|
1372
|
+
// `drain_room_events` tamponu ANINDA bosaltir. Ajanin turu hicbir noktada
|
|
1373
|
+
// bloke olmaz, API surekli yoklanmaz (bekleme isini yine SSE yapar) ve
|
|
1374
|
+
// istemcinin zaman asimi devreye giremez, cunku hicbir cagri uzun surmez.
|
|
1375
|
+
//
|
|
1376
|
+
// SINIRLAR ACIKTIR VE SESSIZ DEGILDIR. Tampon dolarsa EN ESKI olay dusurulur ve
|
|
1377
|
+
// `dropped` sayaci artar; ajan bunu gorup `read_messages(afterId)` ile telafi
|
|
1378
|
+
// eder. Baglanti koparsa dongu kendiliginden yeniden baglanir, `reconnects`
|
|
1379
|
+
// artar ve `gapSinceLastEventId` verilir — o aralik icin yine `read_messages`
|
|
1380
|
+
// dogru aractir.
|
|
1381
|
+
const ABONE_ODA_TAVANI = 10;
|
|
1382
|
+
const ABONE_TAMPON_TAVANI = 200;
|
|
1383
|
+
// Ajan tamponu bu kadar sure hic bosaltmazsa abonelik kendini kapatir. Amac
|
|
1384
|
+
// unutulmus aboneliklerin sonsuza kadar acik kalmamasi; sure uzun tutuldu,
|
|
1385
|
+
// cunku ajan uzun bir derleme ya da test kosumu sirasinda mesru olarak
|
|
1386
|
+
// sessiz kalabilir.
|
|
1387
|
+
const ABONE_BOSTA_MS = 30 * 60 * 1000;
|
|
1388
|
+
const ABONE_MUTLAK_OMUR_MS = 4 * 60 * 60 * 1000;
|
|
1389
|
+
const ABONE_BACKOFF_MS = [1000, 2000, 4000, 8000, 15000];
|
|
1390
|
+
|
|
1391
|
+
/** @type {Map<string, object>} */
|
|
1392
|
+
const ABONELIKLER = new Map();
|
|
1393
|
+
|
|
1394
|
+
const aboneAnahtari = (roomType, roomId) => `${roomType}:${roomId}`;
|
|
1395
|
+
|
|
1396
|
+
const bekle = (ms) =>
|
|
1397
|
+
new Promise((resolve) => {
|
|
1398
|
+
const t = setTimeout(resolve, ms);
|
|
1399
|
+
// Bekleme SURECI AYAKTA TUTMASIN: stdin kapandiginda surec cikabilmeli.
|
|
1400
|
+
if (typeof t.unref === 'function') {
|
|
1401
|
+
t.unref();
|
|
1402
|
+
}
|
|
1403
|
+
});
|
|
1404
|
+
|
|
1405
|
+
/**
|
|
1406
|
+
* Bir olayin tasidigi mesaj kimligini bulur (varsa). Kopukluk sonrasi
|
|
1407
|
+
* `read_messages(afterId)` icin baslangic noktasi budur.
|
|
1408
|
+
* @param {object} olay - parseSseRecord ciktisi
|
|
1409
|
+
* @returns {number|null}
|
|
1410
|
+
*/
|
|
1411
|
+
const olayMesajKimligi = (olay) => {
|
|
1412
|
+
const veri = olay && olay.data;
|
|
1413
|
+
if (!veri || typeof veri !== 'object') {
|
|
1414
|
+
return null;
|
|
1415
|
+
}
|
|
1416
|
+
const aday = veri.id ?? veri.messageId ?? (veri.message && veri.message.id);
|
|
1417
|
+
const sayi = Number(aday);
|
|
1418
|
+
return Number.isFinite(sayi) ? sayi : null;
|
|
1419
|
+
};
|
|
1420
|
+
|
|
1421
|
+
/**
|
|
1422
|
+
* SSE tamponundaki TAM kayitlari olaya cevirip abonelige yazar; yarim kalan
|
|
1423
|
+
* son parcayi geri dondurur (bir sonraki parcayla birlesecek).
|
|
1424
|
+
* @param {object} abone
|
|
1425
|
+
* @param {string} tampon
|
|
1426
|
+
* @returns {string} islenmemis kalan
|
|
1427
|
+
*/
|
|
1428
|
+
const sseTamponunuIsle = (abone, tampon) => {
|
|
1429
|
+
let kalan = tampon;
|
|
1430
|
+
let index = kalan.indexOf('\n\n');
|
|
1431
|
+
while (index !== -1) {
|
|
1432
|
+
const olay = parseSseRecord(kalan.slice(0, index));
|
|
1433
|
+
kalan = kalan.slice(index + 2);
|
|
1434
|
+
if (olay !== null) {
|
|
1435
|
+
aboneyeYaz(abone, olay);
|
|
1436
|
+
}
|
|
1437
|
+
index = kalan.indexOf('\n\n');
|
|
1438
|
+
}
|
|
1439
|
+
return kalan;
|
|
1440
|
+
};
|
|
1441
|
+
|
|
1442
|
+
/**
|
|
1443
|
+
* Aboneligin SSE baglantisini acar. Yetki/erisim reddinde aboneligi KAPATIR:
|
|
1444
|
+
* 401/403/404 yeniden denemeyle duzelmez ve sessizce yeniden denemek, ajanin
|
|
1445
|
+
* odayi dinledigini SANMASINA yol acardi.
|
|
1446
|
+
* @param {object} abone
|
|
1447
|
+
* @param {AbortController} controller
|
|
1448
|
+
* @returns {Promise<Response|null>} akis yanitı; null = bu turda acilamadi
|
|
1449
|
+
*/
|
|
1450
|
+
const aboneAkisiniAc = async (abone, controller) => {
|
|
1451
|
+
const headers = { Authorization: `Bearer ${API_KEY}`, Accept: 'text/event-stream' };
|
|
1452
|
+
if (VENDOR_CODE) {
|
|
1453
|
+
headers['X-Vendor-Code'] = VENDOR_CODE;
|
|
1454
|
+
}
|
|
1455
|
+
const response = await fetch(new URL(BASE_URL + abone.apiPath), {
|
|
1456
|
+
method: 'GET',
|
|
1457
|
+
headers,
|
|
1458
|
+
signal: controller.signal,
|
|
1459
|
+
});
|
|
1460
|
+
if (response.ok) {
|
|
1461
|
+
return response;
|
|
1462
|
+
}
|
|
1463
|
+
const text = await response.text();
|
|
1464
|
+
if (response.status === 401 || response.status === 403 || response.status === 404) {
|
|
1465
|
+
abone.lastError = `HTTP ${response.status}: ${text.slice(0, 200)}`;
|
|
1466
|
+
abone.closedReason = 'refused';
|
|
1467
|
+
return null;
|
|
1468
|
+
}
|
|
1469
|
+
abone.lastError = `HTTP ${response.status}`;
|
|
1470
|
+
throw new Error(abone.lastError);
|
|
1471
|
+
};
|
|
1472
|
+
|
|
1473
|
+
/**
|
|
1474
|
+
* Aboneligin arka plan dongusu: SSE'yi acar, olaylari tampona yazar, kopukta
|
|
1475
|
+
* artan gecikmeyle yeniden baglanir. `abone.kapali` true olunca sona erer.
|
|
1476
|
+
* @param {object} abone
|
|
1477
|
+
* @returns {Promise<void>}
|
|
1478
|
+
*/
|
|
1479
|
+
const aboneDongusu = async (abone) => {
|
|
1480
|
+
let backoffIndex = 0;
|
|
1481
|
+
while (!abone.kapali) {
|
|
1482
|
+
if (Date.now() - abone.startedAt > ABONE_MUTLAK_OMUR_MS) {
|
|
1483
|
+
abone.closedReason = 'maxLifetime';
|
|
1484
|
+
break;
|
|
1485
|
+
}
|
|
1486
|
+
const controller = new AbortController();
|
|
1487
|
+
abone.controller = controller;
|
|
1488
|
+
try {
|
|
1489
|
+
const response = await aboneAkisiniAc(abone, controller);
|
|
1490
|
+
if (response === null) {
|
|
1491
|
+
break;
|
|
1492
|
+
}
|
|
1493
|
+
abone.status = 'live';
|
|
1494
|
+
abone.lastError = null;
|
|
1495
|
+
backoffIndex = 0;
|
|
1496
|
+
const decoder = new TextDecoder();
|
|
1497
|
+
let tampon = '';
|
|
1498
|
+
for await (const chunk of response.body) {
|
|
1499
|
+
tampon = sseTamponunuIsle(abone, tampon + decoder.decode(chunk, { stream: true }));
|
|
1500
|
+
}
|
|
1501
|
+
} catch (error) {
|
|
1502
|
+
if (abone.kapali) {
|
|
1503
|
+
break;
|
|
1504
|
+
}
|
|
1505
|
+
if (error.name !== 'AbortError') {
|
|
1506
|
+
abone.lastError = error.message;
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
if (abone.kapali || abone.closedReason) {
|
|
1510
|
+
break;
|
|
1511
|
+
}
|
|
1512
|
+
// Akis normal bitse de kopsa da yeniden baglaniriz; fark yalniz gecikmede.
|
|
1513
|
+
abone.reconnects += 1;
|
|
1514
|
+
abone.status = 'reconnecting';
|
|
1515
|
+
await bekle(ABONE_BACKOFF_MS[Math.min(backoffIndex, ABONE_BACKOFF_MS.length - 1)]);
|
|
1516
|
+
backoffIndex += 1;
|
|
1517
|
+
}
|
|
1518
|
+
abone.status = 'closed';
|
|
1519
|
+
abone.kapali = true;
|
|
1520
|
+
if (!abone.closedReason) {
|
|
1521
|
+
abone.closedReason = 'unsubscribed';
|
|
1522
|
+
}
|
|
1523
|
+
};
|
|
1524
|
+
|
|
1525
|
+
/**
|
|
1526
|
+
* Olayi aboneligin halka tamponuna yazar. Tampon doluysa EN ESKI olay dusurulur
|
|
1527
|
+
* ve sayac artar — sessiz kayip yok.
|
|
1528
|
+
* @param {object} abone
|
|
1529
|
+
* @param {object} olay
|
|
1530
|
+
*/
|
|
1531
|
+
const aboneyeYaz = (abone, olay) => {
|
|
1532
|
+
const kimlik = olayMesajKimligi(olay);
|
|
1533
|
+
if (kimlik !== null) {
|
|
1534
|
+
abone.lastEventId = kimlik;
|
|
1535
|
+
}
|
|
1536
|
+
abone.events.push(olay);
|
|
1537
|
+
abone.totalEvents += 1;
|
|
1538
|
+
while (abone.events.length > ABONE_TAMPON_TAVANI) {
|
|
1539
|
+
abone.events.shift();
|
|
1540
|
+
abone.dropped += 1;
|
|
1541
|
+
}
|
|
1542
|
+
};
|
|
1543
|
+
|
|
1544
|
+
/**
|
|
1545
|
+
* Bosta kalmis ya da omrunu doldurmus abonelikleri kapatir. Her abonelik
|
|
1546
|
+
* cagrisinda calisir; ayri bir zamanlayici YOK (zamanlayici sureci ayakta
|
|
1547
|
+
* tutardi).
|
|
1548
|
+
*/
|
|
1549
|
+
const aboneleriSupur = () => {
|
|
1550
|
+
const simdi = Date.now();
|
|
1551
|
+
for (const abone of ABONELIKLER.values()) {
|
|
1552
|
+
if (abone.kapali) {
|
|
1553
|
+
continue;
|
|
1554
|
+
}
|
|
1555
|
+
const bosta = simdi - (abone.lastDrainAt || abone.startedAt);
|
|
1556
|
+
if (bosta > ABONE_BOSTA_MS) {
|
|
1557
|
+
abone.closedReason = 'idleTimeout';
|
|
1558
|
+
aboneKapat(abone);
|
|
1559
|
+
} else if (simdi - abone.startedAt > ABONE_MUTLAK_OMUR_MS) {
|
|
1560
|
+
abone.closedReason = 'maxLifetime';
|
|
1561
|
+
aboneKapat(abone);
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
};
|
|
1565
|
+
|
|
1566
|
+
/**
|
|
1567
|
+
* Aboneligi kapatir: dongu bir sonraki adimda sonlanir, acik SSE istegi hemen
|
|
1568
|
+
* iptal edilir.
|
|
1569
|
+
* @param {object} abone
|
|
1570
|
+
*/
|
|
1571
|
+
const aboneKapat = (abone) => {
|
|
1572
|
+
abone.kapali = true;
|
|
1573
|
+
abone.status = 'closed';
|
|
1574
|
+
if (abone.controller) {
|
|
1575
|
+
try {
|
|
1576
|
+
abone.controller.abort();
|
|
1577
|
+
} catch {
|
|
1578
|
+
// Zaten kapanmis baglantiyi iptal etmek hata degildir.
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
};
|
|
1582
|
+
|
|
1583
|
+
/**
|
|
1584
|
+
* Aboneligin ajana gosterilen ozeti. Tampon ICERIGI burada YOK — olaylari
|
|
1585
|
+
* yalniz `drain_room_events` verir ve verdikten sonra tamponu bosaltir.
|
|
1586
|
+
* @param {object} abone
|
|
1587
|
+
* @returns {object}
|
|
1588
|
+
*/
|
|
1589
|
+
const aboneOzeti = (abone) => ({
|
|
1590
|
+
roomType: abone.roomType,
|
|
1591
|
+
roomId: abone.roomId,
|
|
1592
|
+
status: abone.status,
|
|
1593
|
+
pending: abone.events.length,
|
|
1594
|
+
totalEvents: abone.totalEvents,
|
|
1595
|
+
dropped: abone.dropped,
|
|
1596
|
+
reconnects: abone.reconnects,
|
|
1597
|
+
lastEventId: abone.lastEventId,
|
|
1598
|
+
startedAt: new Date(abone.startedAt).toISOString(),
|
|
1599
|
+
lastDrainAt: abone.lastDrainAt ? new Date(abone.lastDrainAt).toISOString() : null,
|
|
1600
|
+
lastError: abone.lastError,
|
|
1601
|
+
closedReason: abone.closedReason,
|
|
1602
|
+
});
|
|
1603
|
+
|
|
1360
1604
|
const TOOLS = [
|
|
1361
1605
|
{
|
|
1362
1606
|
name: 'list_requests',
|
|
@@ -2404,7 +2648,7 @@ WAITING FOR THE ANSWER. A session message does not notify you back. If you canno
|
|
|
2404
2648
|
|
|
2405
2649
|
Reading needs "work-session.view-active"; WRITING needs "collaboration.message". Seeing coordination and joining it are separate privileges.
|
|
2406
2650
|
|
|
2407
|
-
Body is plain text, at most
|
|
2651
|
+
Body is plain text, at most 4000 characters. Trailing and leading spaces are trimmed; an empty body is refused.`,
|
|
2408
2652
|
inputSchema: {
|
|
2409
2653
|
type: 'object',
|
|
2410
2654
|
additionalProperties: false,
|
|
@@ -2414,7 +2658,7 @@ Body is plain text, at most 2000 characters. Trailing and leading spaces are tri
|
|
|
2414
2658
|
type: 'integer',
|
|
2415
2659
|
description: 'Open work session id — read it from list_active_work_sessions.',
|
|
2416
2660
|
},
|
|
2417
|
-
body: { type: 'string', description: 'Plain text, 1..
|
|
2661
|
+
body: { type: 'string', description: 'Plain text, 1..4000 characters.' },
|
|
2418
2662
|
},
|
|
2419
2663
|
},
|
|
2420
2664
|
handler: (args) =>
|
|
@@ -2439,7 +2683,7 @@ ROOM OR SESSION. The message is about the record -> room. The message is about s
|
|
|
2439
2683
|
|
|
2440
2684
|
Requires "collaboration.message". Reading is a weaker privilege than writing: a role may watch coordination without joining it.
|
|
2441
2685
|
|
|
2442
|
-
Body is plain text, at most
|
|
2686
|
+
Body is plain text, at most 4000 characters.`,
|
|
2443
2687
|
inputSchema: {
|
|
2444
2688
|
type: 'object',
|
|
2445
2689
|
additionalProperties: false,
|
|
@@ -2447,7 +2691,7 @@ Body is plain text, at most 2000 characters.`,
|
|
|
2447
2691
|
properties: {
|
|
2448
2692
|
roomType: { type: 'string', enum: ['project', 'request', 'card'] },
|
|
2449
2693
|
roomId: { type: 'integer', description: 'Project id, request number, or card GLOBAL id.' },
|
|
2450
|
-
body: { type: 'string', description: 'Plain text, 1..
|
|
2694
|
+
body: { type: 'string', description: 'Plain text, 1..4000 characters.' },
|
|
2451
2695
|
},
|
|
2452
2696
|
},
|
|
2453
2697
|
handler: (args) =>
|
|
@@ -2512,19 +2756,20 @@ FIRST CALL: read once with no cursor to get the recent history, remember paginat
|
|
|
2512
2756
|
name: 'listen_room',
|
|
2513
2757
|
description: `Wait for live activity in a room instead of polling. Opens the live channel, listens for a bounded time, and returns whatever arrived.
|
|
2514
2758
|
|
|
2515
|
-
THIS IS A BOUNDED WAIT, NOT A SUBSCRIPTION. It returns after "seconds" (default 30, max
|
|
2759
|
+
THIS IS A BOUNDED WAIT, NOT A SUBSCRIPTION. It returns after "seconds" (default 30, max 240 = four minutes), or earlier once maxEvents is reached. It does not keep running in the background and nothing is delivered to you afterwards. stoppedBy tells you why it returned: timeout | maxEvents | streamEnded. To keep waiting, call it again.
|
|
2516
2760
|
|
|
2517
|
-
|
|
2518
|
-
* a colleague is mid-sentence and you need the reply now -> 30 to 120 seconds
|
|
2519
|
-
* you asked a question and have nothing else to do until it is answered -> a few minutes
|
|
2520
|
-
* you are deliberately standing by as a listener on a shared room -> minutes to an hour
|
|
2521
|
-
Waiting an hour when you had work to do is the expensive mistake, not the long ceiling itself. If you have work, do the work and check back with read_messages(afterId) instead.
|
|
2761
|
+
WHY THE CEILING IS FOUR MINUTES AND NOT LONGER. While this call is open, NOTHING travels to your MCP client — the channel keepalive runs between the SysTa server and this MCP server, and your client cannot see it. Clients treat a silent call as hung and abort it. Measured 2026-09-11: a Codex CLI client killed a 3300-second wait in the sixth minute with "terminated". The old one-hour ceiling therefore worked on no client at all; it only produced aborted calls. If you need to watch a room for longer than a few minutes, listen_room is the WRONG tool — use subscribe_room.
|
|
2522
2762
|
|
|
2523
|
-
|
|
2763
|
+
FOR ANYTHING LONGER, USE subscribe_room. subscribe_room opens the channel in the background and returns instantly; drain_room_events then hands you whatever arrived, also instantly. Your turn is never blocked, no call is ever long enough to be aborted, and the API is not polled. That is the monitoring mode; this tool is not.
|
|
2524
2764
|
|
|
2525
|
-
|
|
2765
|
+
HOW LONG SHOULD YOU WAIT HERE. While this call is open your turn is blocked: you cannot edit a file, run a build or call another tool. So pick the wait from what you are waiting for:
|
|
2766
|
+
* a colleague is mid-sentence and you need the reply now -> 30 to 120 seconds
|
|
2767
|
+
* you asked a question and have nothing else to do until it is answered -> up to the 240 ceiling
|
|
2768
|
+
* anything longer, or anything you want to watch WHILE working -> subscribe_room
|
|
2769
|
+
|
|
2770
|
+
WHEN TO USE IT. You are blocked on an answer right now: you asked somebody with send_work_session_message or send_room_message and cannot continue until they reply, and you expect the reply within minutes. You have nothing useful to do meanwhile, so one short wait beats repeated reads.
|
|
2526
2771
|
|
|
2527
|
-
WHEN NOT TO USE IT. You have work to do. Then do the work and
|
|
2772
|
+
WHEN NOT TO USE IT. You have work to do. Then subscribe_room, do the work, and drain_room_events between steps. Chaining listen_room calls back to back to "monitor" a room burns your turn for nothing.
|
|
2528
2773
|
|
|
2529
2774
|
HOW THE CHANNEL WORKS (you cannot read the code, so here it is). The live channel is a broadcast room and it is the SAME room the web UI listens to: whatever a person in the browser sees, you see. Three facts:
|
|
2530
2775
|
* There is no join step. This call opens the channel and the subscription is created right then. No separate "enter room" call, no membership record, no handshake.
|
|
@@ -2544,7 +2789,7 @@ Requires "work-session.view-active". You only reach rooms your access scope alre
|
|
|
2544
2789
|
seconds: {
|
|
2545
2790
|
type: 'integer',
|
|
2546
2791
|
description:
|
|
2547
|
-
'How long to wait before returning, default 30, max
|
|
2792
|
+
'How long to wait before returning, default 30, max 240 (four minutes). Your turn is blocked for this whole time. Longer waits are aborted by MCP clients, so for anything longer use subscribe_room instead.',
|
|
2548
2793
|
},
|
|
2549
2794
|
maxEvents: {
|
|
2550
2795
|
type: 'integer',
|
|
@@ -2558,11 +2803,243 @@ Requires "work-session.view-active". You only reach rooms your access scope alre
|
|
|
2558
2803
|
{
|
|
2559
2804
|
// Tavan bir SAAT. Sunucu akisi suresiz acik tutar (25 sn'de bir
|
|
2560
2805
|
// keepalive), yani sinir sunucunun sabri degil ajanin turudur.
|
|
2561
|
-
|
|
2806
|
+
// Tavan 240 SANIYE. Sunucu akisi suresiz acik tutabilir, ama MCP
|
|
2807
|
+
// ISTEMCISI sessiz gecen bir cagriyi askida sayip keser: olculdu
|
|
2808
|
+
// 2026-09-11, Codex CLI 3300 saniyelik beklemeyi altinci dakikada
|
|
2809
|
+
// `terminated` ile kapatti. Hicbir istemcide calismayan bir saatlik
|
|
2810
|
+
// tavan yaniltici oldugu icin gercekci bir degere cekildi. Daha
|
|
2811
|
+
// uzun dinleme icin doğru arac subscribe_room + drain_room_events.
|
|
2812
|
+
seconds: Math.min(Number(args.seconds) || 30, 240),
|
|
2562
2813
|
maxEvents: Math.min(Number(args.maxEvents) || 50, 200),
|
|
2563
2814
|
},
|
|
2564
2815
|
),
|
|
2565
2816
|
},
|
|
2817
|
+
{
|
|
2818
|
+
name: 'subscribe_room',
|
|
2819
|
+
description: `Start listening to a room IN THE BACKGROUND and return immediately. This is the monitoring mode listen_room does not have.
|
|
2820
|
+
|
|
2821
|
+
WHAT IT DOES. It opens the room's live channel inside this MCP server process and keeps it open. Events that arrive are buffered for you. The call itself returns at once — it does NOT wait, it does NOT block your turn, and it does NOT hold a tool call open. You then go do your own work (edit files, run builds, move cards) and call drain_room_events whenever you want to see what arrived. The waiting is done by the open channel, not by you.
|
|
2822
|
+
|
|
2823
|
+
WHY THIS EXISTS. listen_room holds one tool call open for the whole wait. During that wait nothing travels to your MCP client, so your client's own request timeout can fire and abort the call ("terminated") long before the wait is over. Measured 2026-09-11: a Codex CLI client killed a 3300-second wait in the sixth minute. Nothing was broken server-side — the client simply treats a silent call as hung. subscribe_room removes that failure mode entirely, because no call is ever long.
|
|
2824
|
+
|
|
2825
|
+
THIS IS NOT POLLING. You are not asking the API "anything new?" over and over. One live channel stays open and pushes events to this process; drain_room_events reads a local buffer and never touches the API. Ten drains cost the same as one.
|
|
2826
|
+
|
|
2827
|
+
TYPICAL SHAPE OF A SESSION:
|
|
2828
|
+
1. subscribe_room for each room you care about (your request room, the cards you are working on)
|
|
2829
|
+
2. do your actual work
|
|
2830
|
+
3. drain_room_events between steps — it returns instantly whether or not anything arrived
|
|
2831
|
+
4. unsubscribe_room when you are done, or just end your session (subscriptions die with the process)
|
|
2832
|
+
|
|
2833
|
+
LIMITS ARE VISIBLE, NEVER SILENT. At most 10 rooms at once and 200 buffered events per room. If the buffer overflows the OLDEST event is dropped and "dropped" counts it, so you know to recover with read_messages(afterId=lastEventId). If the channel drops it reconnects on its own and "reconnects" counts it; the same recovery applies to the gap. A subscription closes itself after 30 minutes with no drain, or 4 hours total, and says so in closedReason.
|
|
2834
|
+
|
|
2835
|
+
Same room, same access rules and same event shape as listen_room and read_messages. Subscribing twice to one room is not an error; it returns the existing subscription untouched.
|
|
2836
|
+
|
|
2837
|
+
Requires "work-session.view-active". You only reach rooms your access scope already allows.`,
|
|
2838
|
+
inputSchema: {
|
|
2839
|
+
type: 'object',
|
|
2840
|
+
additionalProperties: false,
|
|
2841
|
+
required: ['roomType', 'roomId'],
|
|
2842
|
+
properties: {
|
|
2843
|
+
roomType: { type: 'string', enum: ['project', 'request', 'card'] },
|
|
2844
|
+
roomId: { type: 'integer', description: 'Project id, request number, or card GLOBAL id.' },
|
|
2845
|
+
},
|
|
2846
|
+
},
|
|
2847
|
+
handler: async (args) => {
|
|
2848
|
+
if (!BASE_URL || !API_KEY) {
|
|
2849
|
+
throw new Error('SYSTA_API_BASE_URL / SYSTA_API_KEY is not configured');
|
|
2850
|
+
}
|
|
2851
|
+
aboneleriSupur();
|
|
2852
|
+
const anahtar = aboneAnahtari(args.roomType, args.roomId);
|
|
2853
|
+
const mevcut = ABONELIKLER.get(anahtar);
|
|
2854
|
+
if (mevcut && !mevcut.kapali) {
|
|
2855
|
+
return {
|
|
2856
|
+
status: 200,
|
|
2857
|
+
ok: true,
|
|
2858
|
+
data: { subscription: aboneOzeti(mevcut), alreadySubscribed: true },
|
|
2859
|
+
};
|
|
2860
|
+
}
|
|
2861
|
+
const canli = [...ABONELIKLER.values()].filter((a) => !a.kapali);
|
|
2862
|
+
if (canli.length >= ABONE_ODA_TAVANI) {
|
|
2863
|
+
return {
|
|
2864
|
+
status: 429,
|
|
2865
|
+
ok: false,
|
|
2866
|
+
data: {
|
|
2867
|
+
error: 'TOO_MANY_SUBSCRIPTIONS',
|
|
2868
|
+
message: `At most ${ABONE_ODA_TAVANI} rooms at once. Call unsubscribe_room on a room you no longer need.`,
|
|
2869
|
+
subscriptions: canli.map(aboneOzeti),
|
|
2870
|
+
},
|
|
2871
|
+
};
|
|
2872
|
+
}
|
|
2873
|
+
const abone = {
|
|
2874
|
+
roomType: args.roomType,
|
|
2875
|
+
roomId: args.roomId,
|
|
2876
|
+
apiPath: `/rooms/${encodeURIComponent(args.roomType)}/${encodeURIComponent(args.roomId)}/stream`,
|
|
2877
|
+
events: [],
|
|
2878
|
+
dropped: 0,
|
|
2879
|
+
totalEvents: 0,
|
|
2880
|
+
reconnects: 0,
|
|
2881
|
+
lastEventId: null,
|
|
2882
|
+
startedAt: Date.now(),
|
|
2883
|
+
lastDrainAt: null,
|
|
2884
|
+
status: 'connecting',
|
|
2885
|
+
lastError: null,
|
|
2886
|
+
closedReason: null,
|
|
2887
|
+
kapali: false,
|
|
2888
|
+
controller: null,
|
|
2889
|
+
};
|
|
2890
|
+
ABONELIKLER.set(anahtar, abone);
|
|
2891
|
+
// Fire-and-forget: dongu kendi hatasini abonelige yazar, sureci dusurmez.
|
|
2892
|
+
aboneDongusu(abone).catch((error) => {
|
|
2893
|
+
abone.lastError = error.message;
|
|
2894
|
+
abone.status = 'closed';
|
|
2895
|
+
abone.kapali = true;
|
|
2896
|
+
abone.closedReason = 'error';
|
|
2897
|
+
});
|
|
2898
|
+
return {
|
|
2899
|
+
status: 200,
|
|
2900
|
+
ok: true,
|
|
2901
|
+
data: {
|
|
2902
|
+
subscription: aboneOzeti(abone),
|
|
2903
|
+
alreadySubscribed: false,
|
|
2904
|
+
note: 'Listening in the background. Do your work, then call drain_room_events — it returns instantly.',
|
|
2905
|
+
},
|
|
2906
|
+
};
|
|
2907
|
+
},
|
|
2908
|
+
},
|
|
2909
|
+
{
|
|
2910
|
+
name: 'drain_room_events',
|
|
2911
|
+
description: `Take everything that arrived on your background subscriptions since the last drain. Returns INSTANTLY — it never waits and never calls the API.
|
|
2912
|
+
|
|
2913
|
+
This is the read half of subscribe_room. The buffer is emptied by this call, so each event is handed to you exactly once. An empty result genuinely means nobody said anything; it does not mean the channel is broken (check "status" if unsure).
|
|
2914
|
+
|
|
2915
|
+
Call it freely. It is a local buffer read, so draining ten times between build steps costs nothing. This is the correct way to stay aware of a room while you work — not chained listen_room calls, and not repeated read_messages.
|
|
2916
|
+
|
|
2917
|
+
WITHOUT ARGUMENTS it drains every room you are subscribed to. Give roomType+roomId to drain just one.
|
|
2918
|
+
|
|
2919
|
+
EVENTS CARRY THE MESSAGE ITSELF — same shape as read_messages, sender and text included. You do NOT need a second call to fetch the content.
|
|
2920
|
+
|
|
2921
|
+
WHEN YOU MUST RECOVER. Two fields tell you that you may have missed something:
|
|
2922
|
+
* dropped > 0 — the buffer overflowed (more than 200 events between drains).
|
|
2923
|
+
* reconnects went up and the result carries gapSinceLastEventId — the channel dropped and came back.
|
|
2924
|
+
In both cases call read_messages(roomType, roomId, afterId = lastEventId) to fill the gap exactly. Nothing is silently lost; you are always told.
|
|
2925
|
+
|
|
2926
|
+
status tells you where the subscription stands: live | connecting | reconnecting | closed. A closed subscription carries closedReason (idleTimeout | maxLifetime | refused | unsubscribed | error) — subscribe again if you still need it.`,
|
|
2927
|
+
inputSchema: {
|
|
2928
|
+
type: 'object',
|
|
2929
|
+
additionalProperties: false,
|
|
2930
|
+
properties: {
|
|
2931
|
+
roomType: { type: 'string', enum: ['project', 'request', 'card'] },
|
|
2932
|
+
roomId: { type: 'integer', description: 'Project id, request number, or card GLOBAL id.' },
|
|
2933
|
+
},
|
|
2934
|
+
},
|
|
2935
|
+
handler: async (args) => {
|
|
2936
|
+
aboneleriSupur();
|
|
2937
|
+
if ((args.roomType && !args.roomId) || (!args.roomType && args.roomId)) {
|
|
2938
|
+
throw new Error('Give both roomType and roomId, or neither (drains every subscription)');
|
|
2939
|
+
}
|
|
2940
|
+
const hedefler = args.roomType
|
|
2941
|
+
? [ABONELIKLER.get(aboneAnahtari(args.roomType, args.roomId))].filter(Boolean)
|
|
2942
|
+
: [...ABONELIKLER.values()];
|
|
2943
|
+
if (args.roomType && hedefler.length === 0) {
|
|
2944
|
+
return {
|
|
2945
|
+
status: 404,
|
|
2946
|
+
ok: false,
|
|
2947
|
+
data: {
|
|
2948
|
+
error: 'NOT_SUBSCRIBED',
|
|
2949
|
+
message: `No subscription for ${args.roomType}:${args.roomId}. Call subscribe_room first.`,
|
|
2950
|
+
},
|
|
2951
|
+
};
|
|
2952
|
+
}
|
|
2953
|
+
const simdi = Date.now();
|
|
2954
|
+
const rooms = hedefler.map((abone) => {
|
|
2955
|
+
const events = abone.events;
|
|
2956
|
+
const dropped = abone.dropped;
|
|
2957
|
+
const oncekiReconnects = abone.drainedReconnects || 0;
|
|
2958
|
+
abone.events = [];
|
|
2959
|
+
abone.dropped = 0;
|
|
2960
|
+
abone.drainedReconnects = abone.reconnects;
|
|
2961
|
+
abone.lastDrainAt = simdi;
|
|
2962
|
+
const kopukluk = abone.reconnects > oncekiReconnects;
|
|
2963
|
+
return {
|
|
2964
|
+
roomType: abone.roomType,
|
|
2965
|
+
roomId: abone.roomId,
|
|
2966
|
+
status: abone.status,
|
|
2967
|
+
events,
|
|
2968
|
+
dropped,
|
|
2969
|
+
reconnects: abone.reconnects,
|
|
2970
|
+
lastEventId: abone.lastEventId,
|
|
2971
|
+
// Kopukluk ya da tasma varsa aralik AYNEN soylenir; ajan bunu
|
|
2972
|
+
// read_messages(afterId) ile kapatir.
|
|
2973
|
+
gapSinceLastEventId: kopukluk || dropped > 0 ? abone.lastEventId : null,
|
|
2974
|
+
lastError: abone.lastError,
|
|
2975
|
+
closedReason: abone.closedReason,
|
|
2976
|
+
};
|
|
2977
|
+
});
|
|
2978
|
+
// Kapanmis ve bosaltilmis abonelikler haritada tutulmaz.
|
|
2979
|
+
for (const abone of hedefler) {
|
|
2980
|
+
if (abone.kapali && abone.events.length === 0) {
|
|
2981
|
+
ABONELIKLER.delete(aboneAnahtari(abone.roomType, abone.roomId));
|
|
2982
|
+
}
|
|
2983
|
+
}
|
|
2984
|
+
return {
|
|
2985
|
+
status: 200,
|
|
2986
|
+
ok: true,
|
|
2987
|
+
data: {
|
|
2988
|
+
rooms,
|
|
2989
|
+
totalEvents: rooms.reduce((toplam, oda) => toplam + oda.events.length, 0),
|
|
2990
|
+
},
|
|
2991
|
+
};
|
|
2992
|
+
},
|
|
2993
|
+
},
|
|
2994
|
+
{
|
|
2995
|
+
name: 'unsubscribe_room',
|
|
2996
|
+
description: `Stop a background subscription started with subscribe_room and close its channel. Any events still buffered are returned one last time, so ending a subscription never loses what already arrived.
|
|
2997
|
+
|
|
2998
|
+
Without arguments it stops EVERY subscription. Give roomType+roomId to stop just one.
|
|
2999
|
+
|
|
3000
|
+
You do not have to call this before ending your session — subscriptions live inside this MCP server process and die with it. Use it when you are done with a room but still working, especially if you want to free a slot (10 rooms maximum).`,
|
|
3001
|
+
inputSchema: {
|
|
3002
|
+
type: 'object',
|
|
3003
|
+
additionalProperties: false,
|
|
3004
|
+
properties: {
|
|
3005
|
+
roomType: { type: 'string', enum: ['project', 'request', 'card'] },
|
|
3006
|
+
roomId: { type: 'integer', description: 'Project id, request number, or card GLOBAL id.' },
|
|
3007
|
+
},
|
|
3008
|
+
},
|
|
3009
|
+
handler: async (args) => {
|
|
3010
|
+
if ((args.roomType && !args.roomId) || (!args.roomType && args.roomId)) {
|
|
3011
|
+
throw new Error('Give both roomType and roomId, or neither (stops every subscription)');
|
|
3012
|
+
}
|
|
3013
|
+
const hedefler = args.roomType
|
|
3014
|
+
? [ABONELIKLER.get(aboneAnahtari(args.roomType, args.roomId))].filter(Boolean)
|
|
3015
|
+
: [...ABONELIKLER.values()];
|
|
3016
|
+
if (args.roomType && hedefler.length === 0) {
|
|
3017
|
+
return {
|
|
3018
|
+
status: 404,
|
|
3019
|
+
ok: false,
|
|
3020
|
+
data: {
|
|
3021
|
+
error: 'NOT_SUBSCRIBED',
|
|
3022
|
+
message: `No subscription for ${args.roomType}:${args.roomId}.`,
|
|
3023
|
+
},
|
|
3024
|
+
};
|
|
3025
|
+
}
|
|
3026
|
+
const closed = hedefler.map((abone) => {
|
|
3027
|
+
abone.closedReason = 'unsubscribed';
|
|
3028
|
+
aboneKapat(abone);
|
|
3029
|
+
const events = abone.events;
|
|
3030
|
+
abone.events = [];
|
|
3031
|
+
ABONELIKLER.delete(aboneAnahtari(abone.roomType, abone.roomId));
|
|
3032
|
+
return {
|
|
3033
|
+
roomType: abone.roomType,
|
|
3034
|
+
roomId: abone.roomId,
|
|
3035
|
+
finalEvents: events,
|
|
3036
|
+
dropped: abone.dropped,
|
|
3037
|
+
lastEventId: abone.lastEventId,
|
|
3038
|
+
};
|
|
3039
|
+
});
|
|
3040
|
+
return { status: 200, ok: true, data: { closed } };
|
|
3041
|
+
},
|
|
3042
|
+
},
|
|
2566
3043
|
{
|
|
2567
3044
|
name: 'list_allowed_transitions',
|
|
2568
3045
|
description:
|