From 9cf111a014681bc218c953a040c9b2a5c3099a57 Mon Sep 17 00:00:00 2001 From: hitalin Date: Wed, 11 Nov 2026 03:39:04 +0900 Subject: [PATCH] =?UTF-8?q?fix(widgets):=20sibyl=20=E3=81=8C=20Mk:api=20?= =?UTF-8?q?=E3=81=AE=E7=B5=90=E6=9E=9C=E3=82=92=E8=A6=8B=E3=81=9A=E3=81=AB?= =?UTF-8?q?=E3=80=8C=E5=9F=B7=E8=A1=8C=E3=81=97=E3=81=BE=E3=81=97=E3=81=9F?= =?UTF-8?q?=E3=80=8D=E3=81=A8=E8=A1=A8=E7=A4=BA=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mk:api は失敗を error 値で返す (本家 Web。NoteDeck も notedeck#1249 で同じ返し方になる)。 結果を見ていなかったので、拒否に失敗しても「執行しました」と表示し、執行済みとして 再試行もしなかった。 - 自動執行と手動の承認 / 拒否は、結果を確かめてから成功を表示する。失敗は失敗と表示する - 一覧の取得に失敗したら状態の行で知らせ、次の巡回で取り直す - 採点できなかった相手は載せず、次の巡回で取り直す Co-Authored-By: Claude Fable 5 --- public/registry/index.json | 12 ++++----- public/registry/plugins.json | 4 +-- public/registry/queries.json | 2 +- public/registry/skills.json | 2 +- public/registry/themes.json | 2 +- public/registry/widgets.json | 11 ++++---- public/registry/widgets/sibyl/api.json | 2 +- public/registry/widgets/sibyl/meta.json | 2 +- public/registry/widgets/sibyl/widget.is | 36 ++++++++++++++++++++----- 9 files changed, 48 insertions(+), 25 deletions(-) diff --git a/public/registry/index.json b/public/registry/index.json index 3247e6a..f98ac22 100644 --- a/public/registry/index.json +++ b/public/registry/index.json @@ -1,24 +1,24 @@ { "version": 1, - "updatedAt": "2026-11-04T11:57:10.395Z", + "updatedAt": "2026-11-10T18:38:15.475Z", "plugins": { "count": 44, - "updatedAt": "2026-11-04T11:57:10.395Z" + "updatedAt": "2026-11-10T18:38:15.475Z" }, "themes": { "count": 77, - "updatedAt": "2026-11-04T11:57:10.395Z" + "updatedAt": "2026-11-10T18:38:15.475Z" }, "widgets": { "count": 70, - "updatedAt": "2026-11-04T11:57:10.395Z" + "updatedAt": "2026-11-10T18:38:15.475Z" }, "skills": { "count": 44, - "updatedAt": "2026-11-04T11:57:10.395Z" + "updatedAt": "2026-11-10T18:38:15.475Z" }, "queries": { "count": 35, - "updatedAt": "2026-11-04T11:57:10.395Z" + "updatedAt": "2026-11-10T18:38:15.475Z" } } diff --git a/public/registry/plugins.json b/public/registry/plugins.json index 13fdf9e..c45ee28 100644 --- a/public/registry/plugins.json +++ b/public/registry/plugins.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-11-04T11:57:10.395Z", + "updatedAt": "2026-11-10T18:38:15.475Z", "plugins": [ { "id": "ai-actions", @@ -131,7 +131,7 @@ "apiUrl": "https://store.notedeck.io/registry/plugins/mfm-search-engine/api.json", "sha512": "595eb52a1034bbcdf014258f6cb09033b31dfed342d86ab8f9ef6f39f86839c009a419ede60e6056c4b6124ed899002a212d16eee7974943d4e5599691c60b15", "createdAt": "2026-11-04T09:14:01+09:00", - "updatedAt": "2026-11-04T09:14:01+09:00", + "updatedAt": "2026-11-04T20:58:07+09:00", "authorUrl": "https://github.com/hitalin", "license": "MIT", "locales": { diff --git a/public/registry/queries.json b/public/registry/queries.json index d48ce2d..4ab3402 100644 --- a/public/registry/queries.json +++ b/public/registry/queries.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-11-04T11:57:10.395Z", + "updatedAt": "2026-11-10T18:38:15.475Z", "queries": [ { "id": "bot-only", diff --git a/public/registry/skills.json b/public/registry/skills.json index d99c5fc..1297d2c 100644 --- a/public/registry/skills.json +++ b/public/registry/skills.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-11-04T11:57:10.395Z", + "updatedAt": "2026-11-10T18:38:15.475Z", "skills": [ { "id": "aiscript-surgeon", diff --git a/public/registry/themes.json b/public/registry/themes.json index fd74b94..3f2bb7e 100644 --- a/public/registry/themes.json +++ b/public/registry/themes.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-11-04T11:57:10.395Z", + "updatedAt": "2026-11-10T18:38:15.475Z", "themes": [ { "id": "adwaita-dark", diff --git a/public/registry/widgets.json b/public/registry/widgets.json index 1e2b2b2..b8b7b8b 100644 --- a/public/registry/widgets.json +++ b/public/registry/widgets.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-11-04T11:57:10.395Z", + "updatedAt": "2026-11-10T18:38:15.475Z", "widgets": [ { "id": "aiscript-health", @@ -1682,7 +1682,7 @@ { "id": "sibyl", "name": "シビュラシステム", - "version": "1.0.0", + "version": "1.0.1", "author": "@hitalin", "description": "保留中のフォローリクエストを定期スキャンし、リクエスト元の犯罪係数 (スパム傾向・信頼度スコア) を算出して一覧表示。承認/拒否ボタンと、係数300以上を自動拒否する自動執行モード (デフォルトOFF・要 account.write 権限) 付き。スコアリングは X 公開アルゴリズム tweepcred の写像で dominator プラグインと同一", "icon": "ti-eye", @@ -1701,7 +1701,7 @@ ], "sourceUrl": "https://store.notedeck.io/registry/widgets/sibyl/widget.is", "apiUrl": "https://store.notedeck.io/registry/widgets/sibyl/api.json", - "sha512": "10c4cdc60b12f0bb960999d30e00600bea5652ab1d96907ce249318014cf084cf684380e7ad2d3cb00673d4ecf8efbf6509b1d516562069818f47cf2c46186f5", + "sha512": "521d2d7aa6518a34c3fb778408c8eca4a17a15eecb4b68b2462b0420e03cf8482b67566f925285baac10754c26a6b2b29d93b13d54d45d87d4cbaf21473fe6de", "createdAt": "2026-07-14T13:43:44+09:00", "updatedAt": "2026-09-25T18:20:04+09:00", "license": "MIT", @@ -1723,7 +1723,6 @@ ], "ndCalls": [], "mkApi": [ - "following/requests/accept", "following/requests/list", "following/requests/reject", "users/notes", @@ -1731,7 +1730,9 @@ ], "hooks": [], "external": false, - "unresolved": [] + "unresolved": [ + "Mk:api@L224" + ] } }, { diff --git a/public/registry/widgets/sibyl/api.json b/public/registry/widgets/sibyl/api.json index 7b7cdc8..f35fad0 100644 --- a/public/registry/widgets/sibyl/api.json +++ b/public/registry/widgets/sibyl/api.json @@ -1 +1 @@ -{"type":"widget","data":"/// @ 1.2.1\n/// シビュラシステム — フォローリクエスト監視・自動執行ウィジェット\n/// 保留中のフォローリクエストを定期スキャンし、リクエスト元の犯罪係数\n/// (スパム傾向・信頼度スコア) を算出して一覧表示する。\n/// スコアリングは X (Twitter) 公開アルゴリズムの tweepcred\n/// (the-algorithm: UserMass.scala / Reputation.scala) と SpamVector の写像。\n/// dominator プラグイン (ユーザーメニュー単発スキャン) と同一の算出式。\n///\n/// - 承認 / 拒否ボタンと自動執行 (係数300以上を自動拒否・デフォルト OFF) には\n/// account.write が必要 → permissions.json5 の plugin プロファイルを full にする\n/// - 一覧取得と採点は account.read / allow のみで動く\n/// - スコアはこのウィジェット内に閉じる (公開・共有機能は無い)\n///\n/// maxStep (生涯累積 100000) 対策: 採点結果はユーザー単位でキャッシュし、\n/// リクエスト一覧が変わらない間のポーリングは署名比較 + 時刻表示更新のみ。\n/// TICK_LIMIT 到達で interval を自前停止して再読み込みを案内する (wellbeing 方式)。\n\nlet POLL_MS = 180000\nlet TICK_LIMIT = 220\n\nlet MUTED = \"8a96a3\"\nlet LINE = \"5a6573\"\nlet CYAN = \"7ac7ff\"\nlet YELLOW = \"ffb300\"\nlet RED = \"ff6961\"\n\nlet AUTO_KEY = \"sibyl:auto\"\n\n// ── 信用度 (0..1) — tweepcred の写像 ──────────────────\n// 詳細な出典と定数の根拠は dominator プラグインのコメント参照 (同一実装)\n\n@computeTrust(user, ageDays) {\n\tif (Obj:has(user, \"isSuspended\") && user.isSuspended) return 0\n\n\tvar trust = 0.05\n\tif (user.avatarUrl != null && user.avatarUrl.index_of(\"identicon\") == -1) { trust += 0.25 }\n\tif (user.description != null && user.description != \"\") { trust += 0.15 }\n\tif (user.notesCount >= 10) { trust += 0.15 }\n\n\tif (ageDays >= 0) {\n\t\tlet ageFactor = Math:min(1, Math:log(1 + ageDays / 15))\n\t\ttrust = trust * ageFactor\n\t}\n\n\tlet followers = user.followersCount\n\tlet following = user.followingCount\n\tif (following > 250) {\n\t\tlet ratio = (following + 1) / (followers + 1)\n\t\tif (ratio > 2) {\n\t\t\tvar div = Math:exp(1.5 * (ratio - 2) * Math:log(Math:log(following)))\n\t\t\tif (div > 50) { div = 50 }\n\t\t\tif (div < 1) { div = 1 }\n\t\t\ttrust = trust / div\n\t\t}\n\t}\n\n\tif (Obj:has(user, \"isFollowed\") && user.isFollowed) { trust += 0.35 }\n\tif (followers >= 1000) { trust += 0.3 }\n\telif (followers >= 100) { trust += 0.15 }\n\tif (trust > 1) { trust = 1 }\n\n\tif (Obj:has(user, \"isSilenced\") && user.isSilenced) { trust = trust * 0.1 }\n\n\tif (following <= 100 && trust < 0.17) { trust = 0.17 }\n\ttrust\n}\n\n@analyzeNotes(notes, trust) {\n\tvar penalty = 0\n\tvar reasons = []\n\tif (notes.len == 0) return { penalty: 0, reasons: [] }\n\n\tvar linkNotes = 0\n\tvar linkNoEngage = 0\n\tvar mentionTotal = 0\n\tvar texts = {}\n\tvar dup = false\n\teach (let n, notes) {\n\t\tvar t = \"\"\n\t\tif (Obj:has(n, \"text\") && n.text != null) { t = n.text }\n\t\tvar eng = 0\n\t\tif (Obj:has(n, \"renoteCount\") && n.renoteCount != null) { eng += n.renoteCount }\n\t\tif (Obj:has(n, \"repliesCount\") && n.repliesCount != null) { eng += n.repliesCount }\n\t\tif (Obj:has(n, \"reactions\") && n.reactions != null) { eng += Obj:keys(n.reactions).len }\n\t\tif (t.index_of(\"http\") != -1) {\n\t\t\tlinkNotes += 1\n\t\t\tif (eng == 0) { linkNoEngage += 1 }\n\t\t}\n\t\tmentionTotal += (t.split(\"@\").len - 1)\n\t\tif (t != \"\") {\n\t\t\tlet k = t.slice(0, 40)\n\t\t\tif (Obj:has(texts, k)) { dup = true } else { Obj:set(texts, k, true) }\n\t\t}\n\t}\n\n\tif (trust < 0.25 && linkNoEngage * 2 > notes.len) {\n\t\tpenalty += 80\n\t\treasons.push(\"低信用×リンク×無反応\")\n\t} elif (linkNotes * 2 > notes.len) {\n\t\tpenalty += 30\n\t\treasons.push(\"リンク投稿多め\")\n\t}\n\tlet mentionAvg10 = Math:floor(mentionTotal * 10 / notes.len)\n\tif (mentionAvg10 >= 30) {\n\t\tpenalty += 50\n\t\treasons.push(\"メンション乱発\")\n\t} elif (mentionAvg10 >= 15) {\n\t\tpenalty += 25\n\t\treasons.push(\"メンション多め\")\n\t}\n\tif (dup) {\n\t\tpenalty += 40\n\t\treasons.push(\"同一文面の反復\")\n\t}\n\t{ penalty: penalty, reasons: reasons }\n}\n\n@scoreUser(user, notes) {\n\tvar ageDays = -1\n\tlet created = Date:parse(user.createdAt)\n\tif (Core:type(created) == \"num\") {\n\t\tageDays = Math:floor((Date:now() - created) / 86400000)\n\t}\n\tlet trust = computeTrust(user, ageDays)\n\tlet behavior = analyzeNotes(notes, trust)\n\n\tvar reasons = []\n\treasons.push(`信用度{Math:round(trust * 100)}`)\n\tif (ageDays >= 0 && ageDays < 30) { reasons.push(`作成{ageDays}日`) }\n\teach (let r, behavior.reasons) { reasons.push(r) }\n\n\tvar coeff = Math:round((1 - trust) * 160) + behavior.penalty\n\tif (Obj:has(user, \"isBot\") && user.isBot) {\n\t\tcoeff += 15\n\t\treasons.push(\"bot\")\n\t}\n\tif (coeff < 0) { coeff = 0 }\n\tif (coeff > 400) { coeff = 400 }\n\t{ value: coeff, reasons: reasons }\n}\n\n// ── 状態 ─────────────────────────────────\nvar pending = []\nvar scoreCache = {}\nvar lastSig = \"(init)\"\nvar autoMode = false\nvar tickCount = 0\nvar stopTick = null\nvar statusC = null\n\nlet savedAuto = Mk:load(AUTO_KEY)\nif (savedAuto != null && Core:type(savedAuto) == \"bool\") { autoMode = savedAuto }\n\n@pad(n) { if (n < 10) `0{n}` else `{n}` }\n\n@coeffColor(coeff) {\n\tif (coeff >= 300) RED\n\telif (coeff >= 100) YELLOW\n\telse CYAN\n}\n\n@acctLabel(user) {\n\tif (Obj:has(user, \"host\") && user.host != null) `@{user.username}@{user.host}`\n\telse `@{user.username}`\n}\n\n// ── UI ───────────────────────────────────\n@statusText() {\n\tlet now = Date:now()\n\tlet time = `{pad(Date:hour(now))}:{pad(Date:minute(now))}`\n\tif (pending.len == 0) `$[fg.color={MUTED} 保留中のフォローリクエストなし · 最終スキャン {time}]`\n\telse `$[fg.color={MUTED} 保留中 {pending.len} 件 · 最終スキャン {time}]`\n}\n\n@rebuild() {\n\tvar body = []\n\tbody.push(Ui:C:mfm({ text: `
$[fg.color={CYAN} **SIBYL SYSTEM**]
` }))\n\tstatusC = Ui:C:mfm({ text: statusText() })\n\tbody.push(statusC)\n\n\teach (let entry, pending) {\n\t\tbody.push(Ui:C:mfm({ text: `$[fg.color={LINE} ───────────────]` }))\n\t\tbody.push(Ui:C:mfm({ text: `**{entry.name}** $[fg.color={MUTED} {entry.acct}]` }))\n\t\tvar line = `$[fg.color={coeffColor(entry.coeff)} 犯罪係数 **{entry.coeff}.{entry.deci}**]`\n\t\tif (entry.executed) {\n\t\t\tline = `{line} $[fg.color={RED} 自動執行済み]`\n\t\t}\n\t\tbody.push(Ui:C:mfm({ text: line }))\n\t\tbody.push(Ui:C:mfm({ text: `$[fg.color={MUTED} {entry.reasons.join(\" / \")}]` }))\n\t\tif (!entry.executed) {\n\t\t\tlet uid = entry.userId\n\t\t\tbody.push(Ui:C:buttons({ buttons: [\n\t\t\t\t{ text: \"承認\", onClick: @() { execute(uid, true) } }\n\t\t\t\t{ text: \"拒否\", onClick: @() { execute(uid, false) } }\n\t\t\t] }))\n\t\t}\n\t}\n\n\tbody.push(Ui:C:mfm({ text: `$[fg.color={LINE} ───────────────]` }))\n\tlet autoLabel = if (autoMode) \"自動執行: ON (係数300以上を拒否)\" else \"自動執行: OFF\"\n\tbody.push(Ui:C:container({ align: \"center\", children: [\n\t\tUi:C:button({ text: autoLabel, rounded: true, onClick: @() {\n\t\t\tautoMode = !autoMode\n\t\t\tMk:save(AUTO_KEY, autoMode)\n\t\t\trebuild()\n\t\t} })\n\t] }))\n\tbody.push(Ui:C:mfm({ text: `
$[fg.color={MUTED} 承認/拒否と自動執行には plugin プロファイルの account.write (full) が必要]
` }))\n\n\tUi:render([\n\t\tUi:C:container({ padding: 10, children: body })\n\t])\n}\n\n// ── 執行 (手動ボタン) ─────────────────────────\n@execute(uid, doAccept) {\n\tif (doAccept) {\n\t\tMk:api(\"following/requests/accept\", { userId: uid })\n\t} else {\n\t\tMk:api(\"following/requests/reject\", { userId: uid })\n\t}\n\tlastSig = \"(dirty)\"\n\tpoll()\n}\n\n// ── ポーリング ─────────────────────────────\n@poll() {\n\tlet list = Mk:api(\"following/requests/list\", {})\n\tvar ids = []\n\teach (let req, list) { ids.push(req.follower.id) }\n\tlet sig = ids.join(\",\")\n\tif (sig == lastSig) {\n\t\t// 変化なし: 時刻表示のみ更新 (低ステップ)\n\t\tif (statusC != null) { statusC.update({ text: statusText() }) }\n\t\treturn null\n\t}\n\tlastSig = sig\n\n\tpending = []\n\teach (let req, list) {\n\t\tlet uid = req.follower.id\n\t\tvar entry = null\n\t\tif (Obj:has(scoreCache, uid)) {\n\t\t\tentry = Obj:get(scoreCache, uid)\n\t\t} else {\n\t\t\tlet user = Mk:api(\"users/show\", { userId: uid })\n\t\t\tlet notes = Mk:api(\"users/notes\", { userId: uid, limit: 10 })\n\t\t\tlet result = scoreUser(user, notes)\n\t\t\tlet name = if (user.name != null) user.name else user.username\n\t\t\tentry = {\n\t\t\t\tuserId: uid\n\t\t\t\tname: name\n\t\t\t\tacct: acctLabel(user)\n\t\t\t\tcoeff: result.value\n\t\t\t\tdeci: user.notesCount % 10\n\t\t\t\treasons: result.reasons\n\t\t\t\texecuted: false\n\t\t\t}\n\t\t\tObj:set(scoreCache, uid, entry)\n\t\t}\n\t\tif (autoMode && entry.coeff >= 300 && !entry.executed) {\n\t\t\tMk:api(\"following/requests/reject\", { userId: uid })\n\t\t\tObj:set(entry, \"executed\", true)\n\t\t\tMk:toast(`犯罪係数 {entry.coeff} — {entry.acct} のリクエストを執行しました`)\n\t\t}\n\t\tpending.push(entry)\n\t}\n\trebuild()\n}\n\n@tick() {\n\ttickCount += 1\n\tif (tickCount >= TICK_LIMIT) {\n\t\tif (stopTick != null) { stopTick() }\n\t\tif (statusC != null) {\n\t\t\tstatusC.update({ text: `$[fg.color={YELLOW} ⏸ 長時間経過のため監視を一時停止しました。ウィジェットを再読み込みすると再開します]` })\n\t\t}\n\t\treturn null\n\t}\n\tpoll()\n}\n\n// ── 起動 ─────────────────────────────────\nrebuild()\npoll()\nstopTick = Async:interval(POLL_MS, tick, false)\n"} +{"type":"widget","data":"/// @ 1.2.1\n/// シビュラシステム — フォローリクエスト監視・自動執行ウィジェット\n/// 保留中のフォローリクエストを定期スキャンし、リクエスト元の犯罪係数\n/// (スパム傾向・信頼度スコア) を算出して一覧表示する。\n/// スコアリングは X (Twitter) 公開アルゴリズムの tweepcred\n/// (the-algorithm: UserMass.scala / Reputation.scala) と SpamVector の写像。\n/// dominator プラグイン (ユーザーメニュー単発スキャン) と同一の算出式。\n///\n/// - 承認 / 拒否ボタンと自動執行 (係数300以上を自動拒否・デフォルト OFF) には\n/// account.write が必要 → permissions.json5 の plugin プロファイルを full にする\n/// - 一覧取得と採点は account.read / allow のみで動く\n/// - スコアはこのウィジェット内に閉じる (公開・共有機能は無い)\n///\n/// maxStep (生涯累積 100000) 対策: 採点結果はユーザー単位でキャッシュし、\n/// リクエスト一覧が変わらない間のポーリングは署名比較 + 時刻表示更新のみ。\n/// TICK_LIMIT 到達で interval を自前停止して再読み込みを案内する (wellbeing 方式)。\n\nlet POLL_MS = 180000\nlet TICK_LIMIT = 220\n\nlet MUTED = \"8a96a3\"\nlet LINE = \"5a6573\"\nlet CYAN = \"7ac7ff\"\nlet YELLOW = \"ffb300\"\nlet RED = \"ff6961\"\n\nlet AUTO_KEY = \"sibyl:auto\"\n\n// ── 信用度 (0..1) — tweepcred の写像 ──────────────────\n// 詳細な出典と定数の根拠は dominator プラグインのコメント参照 (同一実装)\n\n@computeTrust(user, ageDays) {\n\tif (Obj:has(user, \"isSuspended\") && user.isSuspended) return 0\n\n\tvar trust = 0.05\n\tif (user.avatarUrl != null && user.avatarUrl.index_of(\"identicon\") == -1) { trust += 0.25 }\n\tif (user.description != null && user.description != \"\") { trust += 0.15 }\n\tif (user.notesCount >= 10) { trust += 0.15 }\n\n\tif (ageDays >= 0) {\n\t\tlet ageFactor = Math:min(1, Math:log(1 + ageDays / 15))\n\t\ttrust = trust * ageFactor\n\t}\n\n\tlet followers = user.followersCount\n\tlet following = user.followingCount\n\tif (following > 250) {\n\t\tlet ratio = (following + 1) / (followers + 1)\n\t\tif (ratio > 2) {\n\t\t\tvar div = Math:exp(1.5 * (ratio - 2) * Math:log(Math:log(following)))\n\t\t\tif (div > 50) { div = 50 }\n\t\t\tif (div < 1) { div = 1 }\n\t\t\ttrust = trust / div\n\t\t}\n\t}\n\n\tif (Obj:has(user, \"isFollowed\") && user.isFollowed) { trust += 0.35 }\n\tif (followers >= 1000) { trust += 0.3 }\n\telif (followers >= 100) { trust += 0.15 }\n\tif (trust > 1) { trust = 1 }\n\n\tif (Obj:has(user, \"isSilenced\") && user.isSilenced) { trust = trust * 0.1 }\n\n\tif (following <= 100 && trust < 0.17) { trust = 0.17 }\n\ttrust\n}\n\n@analyzeNotes(notes, trust) {\n\tvar penalty = 0\n\tvar reasons = []\n\tif (notes.len == 0) return { penalty: 0, reasons: [] }\n\n\tvar linkNotes = 0\n\tvar linkNoEngage = 0\n\tvar mentionTotal = 0\n\tvar texts = {}\n\tvar dup = false\n\teach (let n, notes) {\n\t\tvar t = \"\"\n\t\tif (Obj:has(n, \"text\") && n.text != null) { t = n.text }\n\t\tvar eng = 0\n\t\tif (Obj:has(n, \"renoteCount\") && n.renoteCount != null) { eng += n.renoteCount }\n\t\tif (Obj:has(n, \"repliesCount\") && n.repliesCount != null) { eng += n.repliesCount }\n\t\tif (Obj:has(n, \"reactions\") && n.reactions != null) { eng += Obj:keys(n.reactions).len }\n\t\tif (t.index_of(\"http\") != -1) {\n\t\t\tlinkNotes += 1\n\t\t\tif (eng == 0) { linkNoEngage += 1 }\n\t\t}\n\t\tmentionTotal += (t.split(\"@\").len - 1)\n\t\tif (t != \"\") {\n\t\t\tlet k = t.slice(0, 40)\n\t\t\tif (Obj:has(texts, k)) { dup = true } else { Obj:set(texts, k, true) }\n\t\t}\n\t}\n\n\tif (trust < 0.25 && linkNoEngage * 2 > notes.len) {\n\t\tpenalty += 80\n\t\treasons.push(\"低信用×リンク×無反応\")\n\t} elif (linkNotes * 2 > notes.len) {\n\t\tpenalty += 30\n\t\treasons.push(\"リンク投稿多め\")\n\t}\n\tlet mentionAvg10 = Math:floor(mentionTotal * 10 / notes.len)\n\tif (mentionAvg10 >= 30) {\n\t\tpenalty += 50\n\t\treasons.push(\"メンション乱発\")\n\t} elif (mentionAvg10 >= 15) {\n\t\tpenalty += 25\n\t\treasons.push(\"メンション多め\")\n\t}\n\tif (dup) {\n\t\tpenalty += 40\n\t\treasons.push(\"同一文面の反復\")\n\t}\n\t{ penalty: penalty, reasons: reasons }\n}\n\n@scoreUser(user, notes) {\n\tvar ageDays = -1\n\tlet created = Date:parse(user.createdAt)\n\tif (Core:type(created) == \"num\") {\n\t\tageDays = Math:floor((Date:now() - created) / 86400000)\n\t}\n\tlet trust = computeTrust(user, ageDays)\n\tlet behavior = analyzeNotes(notes, trust)\n\n\tvar reasons = []\n\treasons.push(`信用度{Math:round(trust * 100)}`)\n\tif (ageDays >= 0 && ageDays < 30) { reasons.push(`作成{ageDays}日`) }\n\teach (let r, behavior.reasons) { reasons.push(r) }\n\n\tvar coeff = Math:round((1 - trust) * 160) + behavior.penalty\n\tif (Obj:has(user, \"isBot\") && user.isBot) {\n\t\tcoeff += 15\n\t\treasons.push(\"bot\")\n\t}\n\tif (coeff < 0) { coeff = 0 }\n\tif (coeff > 400) { coeff = 400 }\n\t{ value: coeff, reasons: reasons }\n}\n\n// ── 状態 ─────────────────────────────────\nvar pending = []\nvar scoreCache = {}\nvar lastSig = \"(init)\"\nvar autoMode = false\nvar tickCount = 0\nvar stopTick = null\nvar statusC = null\n\nlet savedAuto = Mk:load(AUTO_KEY)\nif (savedAuto != null && Core:type(savedAuto) == \"bool\") { autoMode = savedAuto }\n\n@pad(n) { if (n < 10) `0{n}` else `{n}` }\n\n@coeffColor(coeff) {\n\tif (coeff >= 300) RED\n\telif (coeff >= 100) YELLOW\n\telse CYAN\n}\n\n@acctLabel(user) {\n\tif (Obj:has(user, \"host\") && user.host != null) `@{user.username}@{user.host}`\n\telse `@{user.username}`\n}\n\n// ── UI ───────────────────────────────────\n@statusText() {\n\tlet now = Date:now()\n\tlet time = `{pad(Date:hour(now))}:{pad(Date:minute(now))}`\n\tif (pending.len == 0) `$[fg.color={MUTED} 保留中のフォローリクエストなし · 最終スキャン {time}]`\n\telse `$[fg.color={MUTED} 保留中 {pending.len} 件 · 最終スキャン {time}]`\n}\n\n@rebuild() {\n\tvar body = []\n\tbody.push(Ui:C:mfm({ text: `
$[fg.color={CYAN} **SIBYL SYSTEM**]
` }))\n\tstatusC = Ui:C:mfm({ text: statusText() })\n\tbody.push(statusC)\n\n\teach (let entry, pending) {\n\t\tbody.push(Ui:C:mfm({ text: `$[fg.color={LINE} ───────────────]` }))\n\t\tbody.push(Ui:C:mfm({ text: `**{entry.name}** $[fg.color={MUTED} {entry.acct}]` }))\n\t\tvar line = `$[fg.color={coeffColor(entry.coeff)} 犯罪係数 **{entry.coeff}.{entry.deci}**]`\n\t\tif (entry.executed) {\n\t\t\tline = `{line} $[fg.color={RED} 自動執行済み]`\n\t\t}\n\t\tbody.push(Ui:C:mfm({ text: line }))\n\t\tbody.push(Ui:C:mfm({ text: `$[fg.color={MUTED} {entry.reasons.join(\" / \")}]` }))\n\t\tif (!entry.executed) {\n\t\t\tlet uid = entry.userId\n\t\t\tbody.push(Ui:C:buttons({ buttons: [\n\t\t\t\t{ text: \"承認\", onClick: @() { execute(uid, true) } }\n\t\t\t\t{ text: \"拒否\", onClick: @() { execute(uid, false) } }\n\t\t\t] }))\n\t\t}\n\t}\n\n\tbody.push(Ui:C:mfm({ text: `$[fg.color={LINE} ───────────────]` }))\n\tlet autoLabel = if (autoMode) \"自動執行: ON (係数300以上を拒否)\" else \"自動執行: OFF\"\n\tbody.push(Ui:C:container({ align: \"center\", children: [\n\t\tUi:C:button({ text: autoLabel, rounded: true, onClick: @() {\n\t\t\tautoMode = !autoMode\n\t\t\tMk:save(AUTO_KEY, autoMode)\n\t\t\trebuild()\n\t\t} })\n\t] }))\n\tbody.push(Ui:C:mfm({ text: `
$[fg.color={MUTED} 承認/拒否と自動執行には plugin プロファイルの account.write (full) が必要]
` }))\n\n\tUi:render([\n\t\tUi:C:container({ padding: 10, children: body })\n\t])\n}\n\n// Mk:api は失敗を error 値で返す (本家 Web、NoteDeck も同じ)。結果を見ないと、\n// 失敗しても「執行しました」と表示してしまう\n@failed(res) {\n\tCore:type(res) == \"error\"\n}\n\n// ── 執行 (手動ボタン) ─────────────────────────\n@execute(uid, doAccept) {\n\tlet endpoint = if (doAccept) \"following/requests/accept\" else \"following/requests/reject\"\n\tlet res = Mk:api(endpoint, { userId: uid })\n\tif (failed(res)) {\n\t\tMk:toast(\"執行できませんでした。権限 (account.write) と通信を確認してください\")\n\t\treturn null\n\t}\n\tlastSig = \"(dirty)\"\n\tpoll()\n}\n\n// ── ポーリング ─────────────────────────────\n@poll() {\n\tlet list = Mk:api(\"following/requests/list\", {})\n\tif (failed(list)) {\n\t\tif (statusC != null) {\n\t\t\tstatusC.update({ text: `$[fg.color={YELLOW} ⚠ リクエスト一覧を取得できませんでした。次の巡回で再試行します]` })\n\t\t}\n\t\treturn null\n\t}\n\tvar ids = []\n\teach (let req, list) { ids.push(req.follower.id) }\n\tlet sig = ids.join(\",\")\n\tif (sig == lastSig) {\n\t\t// 変化なし: 時刻表示のみ更新 (低ステップ)\n\t\tif (statusC != null) { statusC.update({ text: statusText() }) }\n\t\treturn null\n\t}\n\tlastSig = sig\n\n\tpending = []\n\teach (let req, list) {\n\t\tlet uid = req.follower.id\n\t\tvar entry = null\n\t\tif (Obj:has(scoreCache, uid)) {\n\t\t\tentry = Obj:get(scoreCache, uid)\n\t\t} else {\n\t\t\tlet user = Mk:api(\"users/show\", { userId: uid })\n\t\t\tlet notes = Mk:api(\"users/notes\", { userId: uid, limit: 10 })\n\t\t\tif (failed(user) || failed(notes)) {\n\t\t\t\t// 採点できなかった相手は載せず、次の巡回で取り直す\n\t\t\t\tlastSig = \"(dirty)\"\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tlet result = scoreUser(user, notes)\n\t\t\tlet name = if (user.name != null) user.name else user.username\n\t\t\tentry = {\n\t\t\t\tuserId: uid\n\t\t\t\tname: name\n\t\t\t\tacct: acctLabel(user)\n\t\t\t\tcoeff: result.value\n\t\t\t\tdeci: user.notesCount % 10\n\t\t\t\treasons: result.reasons\n\t\t\t\texecuted: false\n\t\t\t}\n\t\t\tObj:set(scoreCache, uid, entry)\n\t\t}\n\t\tif (autoMode && entry.coeff >= 300 && !entry.executed) {\n\t\t\tlet res = Mk:api(\"following/requests/reject\", { userId: uid })\n\t\t\tif (failed(res)) {\n\t\t\t\tMk:toast(`{entry.acct} のリクエストを執行できませんでした`)\n\t\t\t} else {\n\t\t\t\tObj:set(entry, \"executed\", true)\n\t\t\t\tMk:toast(`犯罪係数 {entry.coeff} — {entry.acct} のリクエストを執行しました`)\n\t\t\t}\n\t\t}\n\t\tpending.push(entry)\n\t}\n\trebuild()\n}\n\n@tick() {\n\ttickCount += 1\n\tif (tickCount >= TICK_LIMIT) {\n\t\tif (stopTick != null) { stopTick() }\n\t\tif (statusC != null) {\n\t\t\tstatusC.update({ text: `$[fg.color={YELLOW} ⏸ 長時間経過のため監視を一時停止しました。ウィジェットを再読み込みすると再開します]` })\n\t\t}\n\t\treturn null\n\t}\n\tpoll()\n}\n\n// ── 起動 ─────────────────────────────────\nrebuild()\npoll()\nstopTick = Async:interval(POLL_MS, tick, false)\n"} diff --git a/public/registry/widgets/sibyl/meta.json b/public/registry/widgets/sibyl/meta.json index 9fc66cf..564346c 100644 --- a/public/registry/widgets/sibyl/meta.json +++ b/public/registry/widgets/sibyl/meta.json @@ -1,7 +1,7 @@ { "id": "sibyl", "name": "シビュラシステム", - "version": "1.0.0", + "version": "1.0.1", "author": "@hitalin", "description": "保留中のフォローリクエストを定期スキャンし、リクエスト元の犯罪係数 (スパム傾向・信頼度スコア) を算出して一覧表示。承認/拒否ボタンと、係数300以上を自動拒否する自動執行モード (デフォルトOFF・要 account.write 権限) 付き。スコアリングは X 公開アルゴリズム tweepcred の写像で dominator プラグインと同一", "icon": "ti-eye", diff --git a/public/registry/widgets/sibyl/widget.is b/public/registry/widgets/sibyl/widget.is index 34d573d..27093ec 100644 --- a/public/registry/widgets/sibyl/widget.is +++ b/public/registry/widgets/sibyl/widget.is @@ -212,12 +212,19 @@ if (savedAuto != null && Core:type(savedAuto) == "bool") { autoMode = savedAuto ]) } +// Mk:api は失敗を error 値で返す (本家 Web、NoteDeck も同じ)。結果を見ないと、 +// 失敗しても「執行しました」と表示してしまう +@failed(res) { + Core:type(res) == "error" +} + // ── 執行 (手動ボタン) ───────────────────────── @execute(uid, doAccept) { - if (doAccept) { - Mk:api("following/requests/accept", { userId: uid }) - } else { - Mk:api("following/requests/reject", { userId: uid }) + let endpoint = if (doAccept) "following/requests/accept" else "following/requests/reject" + let res = Mk:api(endpoint, { userId: uid }) + if (failed(res)) { + Mk:toast("執行できませんでした。権限 (account.write) と通信を確認してください") + return null } lastSig = "(dirty)" poll() @@ -226,6 +233,12 @@ if (savedAuto != null && Core:type(savedAuto) == "bool") { autoMode = savedAuto // ── ポーリング ───────────────────────────── @poll() { let list = Mk:api("following/requests/list", {}) + if (failed(list)) { + if (statusC != null) { + statusC.update({ text: `$[fg.color={YELLOW} ⚠ リクエスト一覧を取得できませんでした。次の巡回で再試行します]` }) + } + return null + } var ids = [] each (let req, list) { ids.push(req.follower.id) } let sig = ids.join(",") @@ -245,6 +258,11 @@ if (savedAuto != null && Core:type(savedAuto) == "bool") { autoMode = savedAuto } else { let user = Mk:api("users/show", { userId: uid }) let notes = Mk:api("users/notes", { userId: uid, limit: 10 }) + if (failed(user) || failed(notes)) { + // 採点できなかった相手は載せず、次の巡回で取り直す + lastSig = "(dirty)" + continue + } let result = scoreUser(user, notes) let name = if (user.name != null) user.name else user.username entry = { @@ -259,9 +277,13 @@ if (savedAuto != null && Core:type(savedAuto) == "bool") { autoMode = savedAuto Obj:set(scoreCache, uid, entry) } if (autoMode && entry.coeff >= 300 && !entry.executed) { - Mk:api("following/requests/reject", { userId: uid }) - Obj:set(entry, "executed", true) - Mk:toast(`犯罪係数 {entry.coeff} — {entry.acct} のリクエストを執行しました`) + let res = Mk:api("following/requests/reject", { userId: uid }) + if (failed(res)) { + Mk:toast(`{entry.acct} のリクエストを執行できませんでした`) + } else { + Obj:set(entry, "executed", true) + Mk:toast(`犯罪係数 {entry.coeff} — {entry.acct} のリクエストを執行しました`) + } } pending.push(entry) }