recala-cli 0.6.0__py3-none-any.whl
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.
- recala_cli/__init__.py +3 -0
- recala_cli/__main__.py +3 -0
- recala_cli/article_runs.py +372 -0
- recala_cli/auth.py +196 -0
- recala_cli/client.py +103 -0
- recala_cli/commands/__init__.py +1 -0
- recala_cli/commands/article.py +835 -0
- recala_cli/commands/auth.py +207 -0
- recala_cli/commands/billing.py +76 -0
- recala_cli/commands/bing.py +164 -0
- recala_cli/commands/cadence.py +217 -0
- recala_cli/commands/connect.py +395 -0
- recala_cli/commands/credits.py +46 -0
- recala_cli/commands/faithful_translations.py +70 -0
- recala_cli/commands/gsc.py +79 -0
- recala_cli/commands/index.py +293 -0
- recala_cli/commands/keys.py +98 -0
- recala_cli/commands/knowledge.py +245 -0
- recala_cli/commands/languages.py +119 -0
- recala_cli/commands/monthly_report.py +152 -0
- recala_cli/commands/notifications.py +106 -0
- recala_cli/commands/onboard.py +237 -0
- recala_cli/commands/org.py +352 -0
- recala_cli/commands/profile.py +70 -0
- recala_cli/commands/questions.py +246 -0
- recala_cli/commands/reads.py +320 -0
- recala_cli/commands/refresh.py +138 -0
- recala_cli/commands/refusals.py +20 -0
- recala_cli/commands/schema.py +47 -0
- recala_cli/commands/shell.py +81 -0
- recala_cli/commands/skills.py +92 -0
- recala_cli/commands/slot_topic.py +94 -0
- recala_cli/commands/status.py +153 -0
- recala_cli/commands/topics.py +328 -0
- recala_cli/config.py +176 -0
- recala_cli/confirm.py +137 -0
- recala_cli/display.py +40 -0
- recala_cli/endpoints.py +258 -0
- recala_cli/errors.py +220 -0
- recala_cli/human.py +324 -0
- recala_cli/ids.py +139 -0
- recala_cli/labels.py +74 -0
- recala_cli/main.py +129 -0
- recala_cli/onboarding.py +522 -0
- recala_cli/organizations.py +197 -0
- recala_cli/output.py +34 -0
- recala_cli/parallel.py +18 -0
- recala_cli/polling.py +102 -0
- recala_cli/prompts.py +88 -0
- recala_cli/runtime.py +216 -0
- recala_cli/search_numbers.py +22 -0
- recala_cli/skills/recala-apply-revision-notes/SKILL.md +39 -0
- recala_cli/skills/recala-article-signoff/SKILL.md +67 -0
- recala_cli/skills/recala-cli-contract.md +78 -0
- recala_cli/skills/recala-connect-search-console/SKILL.md +29 -0
- recala_cli/skills/recala-onboard/SKILL.md +25 -0
- recala_cli/skills/recala-onboard-brand/SKILL.md +25 -0
- recala_cli/skills/recala-plan-month/SKILL.md +33 -0
- recala_cli/skills/recala-schedule-approved/SKILL.md +36 -0
- recala_cli/skills/recala-weekly-authority-report/SKILL.md +35 -0
- recala_cli/ui.py +260 -0
- recala_cli/updates.py +43 -0
- recala_cli/views.py +372 -0
- recala_cli-0.6.0.dist-info/METADATA +63 -0
- recala_cli-0.6.0.dist-info/RECORD +67 -0
- recala_cli-0.6.0.dist-info/WHEEL +4 -0
- recala_cli-0.6.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""`recala login`, `recala logout`, `recala whoami` and `recala grant`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from contextlib import AbstractContextManager, nullcontext
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from recala_cli import auth, config, ui
|
|
12
|
+
from recala_cli.auth import credential_kind
|
|
13
|
+
from recala_cli.client import RecalaClient
|
|
14
|
+
from recala_cli.confirm import GRANT_HOURS, grant_status, is_interactive, record_grant, require_current_signin
|
|
15
|
+
from recala_cli.endpoints import ME, SIGN_OUT
|
|
16
|
+
from recala_cli.display import display_name
|
|
17
|
+
from recala_cli.errors import CliError, ExitCode
|
|
18
|
+
from recala_cli.output import notify
|
|
19
|
+
from recala_cli.organizations import (
|
|
20
|
+
ORGANIZATION_LOOKUP_FAILED,
|
|
21
|
+
organization_choices,
|
|
22
|
+
organization_description,
|
|
23
|
+
organization_memberships,
|
|
24
|
+
organization_next_step,
|
|
25
|
+
)
|
|
26
|
+
from recala_cli.runtime import command_runner, common_options, interactive_human_output, json_option, open_client, accepted_yes_option
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _describe_sign_in(result: dict[str, Any]) -> str:
|
|
30
|
+
if not result.get("signed_in"):
|
|
31
|
+
return "Signed out."
|
|
32
|
+
who = result.get("email") or result.get("kind", "token")
|
|
33
|
+
return f"Signed in ({who})."
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SignInScreen:
|
|
37
|
+
"""The terminal half of the sign-in: the wordmark, the code, a pulse while the browser half happens."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, *, as_json: bool, within_onboarding: bool = False) -> None:
|
|
40
|
+
self.as_json = as_json
|
|
41
|
+
self.within_onboarding = within_onboarding # the journey drew the wordmark already
|
|
42
|
+
self.style = ui.Style.for_stream()
|
|
43
|
+
|
|
44
|
+
def present_code(self, uri: str, code: str) -> None:
|
|
45
|
+
if self.as_json: # agents and logs: one plain line, before the JSON answer
|
|
46
|
+
notify(f"To sign in, open {uri} and enter the code {code}")
|
|
47
|
+
return
|
|
48
|
+
if not self.within_onboarding:
|
|
49
|
+
ui.banner("Sign in to Recala", style=self.style)
|
|
50
|
+
spaced = " ".join(code)
|
|
51
|
+
notify(f" Your code {self.style.bold(self.style.accent(spaced))}\n")
|
|
52
|
+
notify(f" Open {uri} and enter it there.")
|
|
53
|
+
notify(" New here? Create an account: https://recala.co/register")
|
|
54
|
+
notify(f" {self.style.dim('Only approve a code you just requested. Recala never asks for it by email.')}\n")
|
|
55
|
+
|
|
56
|
+
def waiting(self) -> AbstractContextManager[Any]:
|
|
57
|
+
if self.as_json:
|
|
58
|
+
return nullcontext()
|
|
59
|
+
return ui.Spinner("Waiting for you to approve the code in your browser", style=self.style)
|
|
60
|
+
|
|
61
|
+
def signed_in(self, email: str | None) -> None:
|
|
62
|
+
if self.as_json:
|
|
63
|
+
return
|
|
64
|
+
if self.within_onboarding:
|
|
65
|
+
notify(f"\n {self.style.accent('*')} {self.style.bold('Signed in')} as {display_name(email) or 'you'}\n")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _describe_login(ctx: click.Context, result: dict[str, Any]) -> str:
|
|
69
|
+
if ctx.params.get("with_token") and not interactive_human_output(ctx):
|
|
70
|
+
return _describe_sign_in(result)
|
|
71
|
+
me = ctx.meta.get("login_identity") or {}
|
|
72
|
+
user = me.get("user") if isinstance(me.get("user"), dict) else {}
|
|
73
|
+
name = display_name(user.get("name") or user.get("display_name") or user.get("email") or result.get("email"))
|
|
74
|
+
lines = [f"Signed in as {name or result.get('kind') or 'you'}."]
|
|
75
|
+
if "login_identity" not in ctx.meta:
|
|
76
|
+
return "\n".join([*lines, ORGANIZATION_LOOKUP_FAILED])
|
|
77
|
+
rows = me.get("memberships") or []
|
|
78
|
+
if len(rows) == 1:
|
|
79
|
+
lines.extend([f"Using {display_name(rows[0].get('name')) or rows[0]['org_id']}.", "Next: recala plan"])
|
|
80
|
+
else:
|
|
81
|
+
lines.extend([organization_choices(rows), organization_next_step(rows)])
|
|
82
|
+
return "\n".join(lines)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _login_next_step(ctx: click.Context) -> None:
|
|
86
|
+
try:
|
|
87
|
+
client = RecalaClient(ctx.obj["api_url"], transport=ctx.obj.get("transport"))
|
|
88
|
+
client.token = auth.bearer_token(client)
|
|
89
|
+
me = client.call(ME)
|
|
90
|
+
rows = organization_memberships(me, skip_invalid=True)
|
|
91
|
+
except (CliError, ValueError):
|
|
92
|
+
return # successful sign-in stays successful during a presentation outage
|
|
93
|
+
ctx.meta["login_identity"] = {**me, "memberships": rows}
|
|
94
|
+
if len(rows) == 1:
|
|
95
|
+
config.set_active_org(rows[0]["org_id"])
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@click.command()
|
|
99
|
+
@json_option
|
|
100
|
+
@command_runner(contextual_human=_describe_login)
|
|
101
|
+
@click.option("--with-token", is_flag=True, help="Read an API key or access token from standard input (for agents and CI).")
|
|
102
|
+
@click.option("--no-browser", is_flag=True, help="Print the sign-in link without opening a browser.")
|
|
103
|
+
def login(ctx: click.Context, with_token: bool, no_browser: bool) -> dict[str, Any]:
|
|
104
|
+
"""Sign in with a one-time code in the browser.
|
|
105
|
+
|
|
106
|
+
No account yet? Create one at https://recala.co/register. Agents and CI
|
|
107
|
+
pipe a key with --with-token."""
|
|
108
|
+
if with_token:
|
|
109
|
+
result = auth.store_pasted_token(sys.stdin.readline())
|
|
110
|
+
else:
|
|
111
|
+
result = sign_in(ctx, no_browser=no_browser)
|
|
112
|
+
if not ctx.obj.get("as_json") and (not with_token or interactive_human_output(ctx)):
|
|
113
|
+
_login_next_step(ctx)
|
|
114
|
+
return result
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def sign_in(ctx: click.Context, *, no_browser: bool = False, within_onboarding: bool = False) -> dict[str, Any]:
|
|
118
|
+
seams = ctx.find_root().obj
|
|
119
|
+
screen = SignInScreen(as_json=bool(seams.get("as_json")), within_onboarding=within_onboarding)
|
|
120
|
+
client = RecalaClient(seams["api_url"], transport=seams.get("transport"))
|
|
121
|
+
result = auth.device_login(
|
|
122
|
+
client,
|
|
123
|
+
sleep=seams["sleep"],
|
|
124
|
+
open_browser=None if no_browser else seams["open_browser"],
|
|
125
|
+
present_code=screen.present_code,
|
|
126
|
+
waiting=screen.waiting,
|
|
127
|
+
)
|
|
128
|
+
screen.signed_in(result.get("email"))
|
|
129
|
+
return result
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@click.command()
|
|
133
|
+
@json_option
|
|
134
|
+
@command_runner(human=_describe_sign_in)
|
|
135
|
+
@accepted_yes_option
|
|
136
|
+
def logout(ctx: click.Context) -> dict[str, Any]:
|
|
137
|
+
"""End this machine's session and forget it here.
|
|
138
|
+
|
|
139
|
+
An API key stays valid until revoked."""
|
|
140
|
+
stored = config.load_credentials()
|
|
141
|
+
server_revoked = False
|
|
142
|
+
if stored.get("kind") == "session" and stored.get("access_token"):
|
|
143
|
+
client = RecalaClient(ctx.obj["api_url"], transport=ctx.obj.get("transport"))
|
|
144
|
+
client.token = str(stored["access_token"])
|
|
145
|
+
try:
|
|
146
|
+
client.call(SIGN_OUT)
|
|
147
|
+
server_revoked = True
|
|
148
|
+
except CliError:
|
|
149
|
+
server_revoked = False # forgetting it here is still what the person asked for
|
|
150
|
+
config.forget_credentials()
|
|
151
|
+
return {"signed_in": False, "server_revoked": server_revoked}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _describe_identity(result: dict[str, Any]) -> str:
|
|
155
|
+
lines = [f"{result['user'].get('email') or result['user'].get('id')} ({result['credential']})"]
|
|
156
|
+
active = result.get("active_org")
|
|
157
|
+
rows = result["memberships"]
|
|
158
|
+
if active:
|
|
159
|
+
selected = next((row for row in rows if row["org_id"] == active), {"org_id": active})
|
|
160
|
+
lines.append(f"Active company: {organization_description(selected)} [{active[:8]}]")
|
|
161
|
+
else:
|
|
162
|
+
lines.extend(["No company selected. Visible companies:", organization_choices(rows),
|
|
163
|
+
organization_next_step(rows)])
|
|
164
|
+
lines.append(f"{len(result['memberships'])} company(ies): recala company list")
|
|
165
|
+
return "\n".join(lines)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@click.command()
|
|
169
|
+
@common_options
|
|
170
|
+
@command_runner(human=_describe_identity)
|
|
171
|
+
def whoami(ctx: click.Context, org_flag: str | None) -> dict[str, Any]:
|
|
172
|
+
"""Who this credential is and what it may act on.
|
|
173
|
+
|
|
174
|
+
Its companies, and the active one."""
|
|
175
|
+
client = open_client(ctx, org_flag, require_org=False)
|
|
176
|
+
me: dict[str, Any] = client.call(ME)
|
|
177
|
+
return {**me, "credential": credential_kind(), "active_org": ctx.meta.get("resolved_org")}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _describe_grant(result: dict[str, Any]) -> str:
|
|
181
|
+
if not result.get("active"):
|
|
182
|
+
return "Every write asks you first ([y]es / [n]o / [a]ll)."
|
|
183
|
+
return f"Writes are confirmed for this sign-in until logout or {GRANT_HOURS} hours. Undo: recala grant --revoke"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@click.command()
|
|
187
|
+
@json_option
|
|
188
|
+
@command_runner(human=_describe_grant)
|
|
189
|
+
@click.option("--status", "show_status", is_flag=True, help="Show whether writes are confirmed for this sign-in.")
|
|
190
|
+
@click.option("--revoke", is_flag=True, help="Ask before every write again.")
|
|
191
|
+
def grant(ctx: click.Context, show_status: bool, revoke: bool) -> dict[str, Any]:
|
|
192
|
+
"""Confirm every write for this sign-in at once.
|
|
193
|
+
|
|
194
|
+
The [a]ll answer, so an agent can work without prompts."""
|
|
195
|
+
if revoke:
|
|
196
|
+
config.forget_grant()
|
|
197
|
+
return grant_status()
|
|
198
|
+
if show_status:
|
|
199
|
+
return grant_status()
|
|
200
|
+
require_current_signin()
|
|
201
|
+
if not is_interactive(ctx):
|
|
202
|
+
raise CliError(ExitCode.CONFIRMATION_REQUIRED, "confirmation_required",
|
|
203
|
+
"Only you can give this permission: run `recala grant` in a terminal.")
|
|
204
|
+
if not click.confirm(f" Let writes run without asking, for this sign-in only (until logout or {GRANT_HOURS} hours)?",
|
|
205
|
+
default=False, err=True):
|
|
206
|
+
raise CliError(ExitCode.CONFIRMATION_REQUIRED, "declined", "Nothing changed: every write still asks you.")
|
|
207
|
+
return record_grant()
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""`recala billing`: payments for the company's plan."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
|
|
11
|
+
from recala_cli import config
|
|
12
|
+
from recala_cli.commands.refusals import refusals_in_words
|
|
13
|
+
from recala_cli.endpoints import BILLING_INVOICES, BILLING_PAY_LINK
|
|
14
|
+
from recala_cli.runtime import command_runner, common_options, confirm, open_client, accepted_yes_option, dry_run_option
|
|
15
|
+
from recala_cli.views import CARD_PLAN_LABELS, describe_pay_link
|
|
16
|
+
|
|
17
|
+
INVOICE_STATUSES = {"paid": "paid", "open": "waiting for payment", "draft": "not issued yet", "void": "cancelled",
|
|
18
|
+
"uncollectible": "could not be collected"}
|
|
19
|
+
NO_INVOICES_FEATURE = "Invoices are not switched on for this company yet. Your billing page in the portal has them."
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@click.group()
|
|
23
|
+
def billing() -> None:
|
|
24
|
+
"""Payments for the company's plan (owners)."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def money(cents: int, currency: str) -> str:
|
|
28
|
+
return f"{cents / 100:,.2f} {currency.upper()}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def describe_invoices(result: dict[str, Any]) -> str:
|
|
32
|
+
portal = f"{config.web_url()}/portal/billing"
|
|
33
|
+
invoices = result.get("invoices") or []
|
|
34
|
+
if not invoices:
|
|
35
|
+
return f"No invoices yet.\nPayment method and plan changes: {portal}"
|
|
36
|
+
lines = [f"{len(invoices)} most recent invoice{'' if len(invoices) == 1 else 's'}, newest first:"]
|
|
37
|
+
for invoice in invoices:
|
|
38
|
+
day = datetime.fromtimestamp(int(invoice["created"]), tz=timezone.utc).strftime("%d %b %Y").lstrip("0")
|
|
39
|
+
status = INVOICE_STATUSES.get(invoice.get("status") or "", str(invoice.get("status") or "unknown"))
|
|
40
|
+
lines.append(f" {day} {money(invoice['total'], invoice['currency'])} {status}"
|
|
41
|
+
+ (f" #{invoice['number']}" if invoice.get("number") else ""))
|
|
42
|
+
if invoice.get("url") and invoice.get("status") != "paid":
|
|
43
|
+
lines.append(f" {invoice['url']}")
|
|
44
|
+
lines.append(f"Payment method and plan changes: {portal}")
|
|
45
|
+
return "\n".join(lines)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@billing.command("invoices")
|
|
49
|
+
@common_options
|
|
50
|
+
@command_runner(human=describe_invoices)
|
|
51
|
+
def invoices(ctx: click.Context, org_flag: str | None) -> dict[str, Any]:
|
|
52
|
+
"""The company's recent invoices: date, amount and status (owners).
|
|
53
|
+
|
|
54
|
+
Payment method and plan changes happen in the portal; the last line links to it."""
|
|
55
|
+
with refusals_in_words(not_found=NO_INVOICES_FEATURE):
|
|
56
|
+
listed: dict[str, Any] = open_client(ctx, org_flag).call(BILLING_INVOICES)
|
|
57
|
+
return listed
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@billing.command("link")
|
|
61
|
+
@common_options
|
|
62
|
+
@command_runner(human=describe_pay_link)
|
|
63
|
+
@click.option("--plan", "plan_key", type=click.Choice(tuple(CARD_PLAN_LABELS)), required=True, help="The monthly plan to sell.")
|
|
64
|
+
@click.option("--email", default=None, help="Billing email for receipts (else the current one; yours for a new customer).")
|
|
65
|
+
@accepted_yes_option
|
|
66
|
+
@dry_run_option
|
|
67
|
+
def pay_link(ctx: click.Context, org_flag: str | None, plan_key: str, email: str | None) -> dict[str, Any]:
|
|
68
|
+
"""A 30-day payment link for whoever pays.
|
|
69
|
+
|
|
70
|
+
Email it to the payer; once paid, the plan is granted with no manual step.
|
|
71
|
+
"""
|
|
72
|
+
client = open_client(ctx, org_flag)
|
|
73
|
+
confirm(ctx, client, f"Create a payment link for {CARD_PLAN_LABELS[plan_key]} (monthly)? Anyone with it can pay it.")
|
|
74
|
+
body: dict[str, Any] = {"plan": plan_key, **({"email": email} if email else {})}
|
|
75
|
+
link: dict[str, Any] = client.call(BILLING_PAY_LINK, json=body)
|
|
76
|
+
return link
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Bing Webmaster Tools: connect through the portal's sign-in, choose the site, read the stored numbers.
|
|
2
|
+
|
|
3
|
+
The numbers come from what Recala has stored, never from a live call to Bing, so exploring them costs nothing.
|
|
4
|
+
Queries and pages are weekly figures and the totals are daily, so they are shown apart and never added together.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
|
|
13
|
+
from recala_cli import config
|
|
14
|
+
from recala_cli.client import RecalaClient
|
|
15
|
+
from recala_cli.commands.connect import connect
|
|
16
|
+
from recala_cli.endpoints import BING_PERFORMANCE, BING_SITE, BING_SITES, BING_STATUS, Endpoint
|
|
17
|
+
from recala_cli.errors import CliError, ExitCode
|
|
18
|
+
from recala_cli.polling import BROWSER_POLL_SECONDS, open_in_browser, poll_until
|
|
19
|
+
from recala_cli.prompts import choose
|
|
20
|
+
from recala_cli.runtime import command_runner, common_options, confirm, open_client, optional_read, accepted_yes_option, dry_run_option
|
|
21
|
+
from recala_cli.search_numbers import ranked_lines, totals_sentence
|
|
22
|
+
|
|
23
|
+
SIGN_IN_WAIT_SECONDS = 600
|
|
24
|
+
NOT_CONNECTED = "Bing Webmaster Tools isn't connected for this company. Connect it: recala connect bing"
|
|
25
|
+
SWITCHED_OFF = "Bing Webmaster Tools isn't switched on for this company yet."
|
|
26
|
+
_NEEDS_SIGN_IN = ("not_connected", "needs_reconnect", None)
|
|
27
|
+
_STATE_SENTENCES = {
|
|
28
|
+
"connecting": "Signed in to Microsoft. Choose the site: recala bing use",
|
|
29
|
+
"needs_reconnect": "{site}: Bing access was lost. Reconnect: recala connect bing",
|
|
30
|
+
"backfilling": "{site}: first import running.",
|
|
31
|
+
"import_retrying": "{site}: the first import failed and will retry. Bing said: {last_error}",
|
|
32
|
+
"waiting_for_data": "{site}: connected, and Bing has no search data for it yet.",
|
|
33
|
+
"live": "{site}: live, data through {data_through}.",
|
|
34
|
+
"stale": "{site}: updates stopped on {stale_since}.",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def describe_status(status: dict[str, Any]) -> str:
|
|
39
|
+
site = (status.get("property") or "").removeprefix("https://").removeprefix("http://").rstrip("/")
|
|
40
|
+
template = _STATE_SENTENCES.get(status.get("state") or "", NOT_CONNECTED)
|
|
41
|
+
return template.format(**{**status, "site": site})
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _call(client: RecalaClient, endpoint: Endpoint, **request: Any) -> Any:
|
|
45
|
+
"""A Bing route's refusals, in words: off the rollout (404) or not connected (409)."""
|
|
46
|
+
try:
|
|
47
|
+
return client.call(endpoint, **request)
|
|
48
|
+
except CliError as error:
|
|
49
|
+
if error.exit_code == ExitCode.NOT_FOUND:
|
|
50
|
+
raise CliError(ExitCode.NOT_FOUND, "bing_unavailable", SWITCHED_OFF, 404) from error
|
|
51
|
+
if error.exit_code == ExitCode.CONFLICT:
|
|
52
|
+
raise CliError(ExitCode.CONFLICT, "bing_not_connected", NOT_CONNECTED, 409) from error
|
|
53
|
+
raise
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _read_status(client: RecalaClient) -> dict[str, Any]:
|
|
57
|
+
status: dict[str, Any] | None = optional_read(client, BING_STATUS)
|
|
58
|
+
if status is None:
|
|
59
|
+
raise CliError(ExitCode.NOT_FOUND, "bing_unavailable", SWITCHED_OFF, 404)
|
|
60
|
+
return status
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _choose_site(ctx: click.Context, client: RecalaClient, site: str | None) -> dict[str, Any]:
|
|
64
|
+
listed = _call(client, BING_SITES) or {}
|
|
65
|
+
sites = [entry["site_url"] for entry in listed.get("sites") or []]
|
|
66
|
+
if not sites:
|
|
67
|
+
raise CliError(ExitCode.CONFLICT, "no_sites",
|
|
68
|
+
"This Microsoft account has no verified site in Bing Webmaster Tools. Verify the site there, "
|
|
69
|
+
"or import it from Search Console, then run: recala connect bing. "
|
|
70
|
+
"Wrong Microsoft account? Disconnect on the Settings page, then connect again.")
|
|
71
|
+
chosen = choose(ctx, [(url, url) for url in sites], label="Which site is this company's?", flag="--site", value=site,
|
|
72
|
+
default=listed.get("suggested_site"))
|
|
73
|
+
confirm(ctx, client, f"Use the Bing site {chosen}?")
|
|
74
|
+
_call(client, BING_SITE, json={"site_url": chosen})
|
|
75
|
+
return _read_status(client)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@connect.command("bing")
|
|
79
|
+
@common_options
|
|
80
|
+
@command_runner(human=describe_status)
|
|
81
|
+
@click.option("--site", default=None, help="The site to use, as listed by: recala bing sites.")
|
|
82
|
+
@accepted_yes_option
|
|
83
|
+
@dry_run_option
|
|
84
|
+
def connect_bing(ctx: click.Context, org_flag: str | None, site: str | None) -> dict[str, Any]:
|
|
85
|
+
"""Connect Bing Webmaster Tools.
|
|
86
|
+
|
|
87
|
+
Microsoft sign-in in the browser, then choose the site."""
|
|
88
|
+
client = open_client(ctx, org_flag)
|
|
89
|
+
current = _read_status(client)
|
|
90
|
+
if current.get("state") in _NEEDS_SIGN_IN:
|
|
91
|
+
open_in_browser(ctx, f"{config.web_url()}/portal/settings?org={client.org_id}",
|
|
92
|
+
why="In your browser, press Connect on the Bing Webmaster Tools card and approve Recala:")
|
|
93
|
+
current = poll_until(ctx, lambda: _read_status(client), lambda s: s.get("state") not in _NEEDS_SIGN_IN,
|
|
94
|
+
label="Waiting for the Microsoft sign-in in your browser",
|
|
95
|
+
timeout_seconds=SIGN_IN_WAIT_SECONDS, interval_seconds=BROWSER_POLL_SECONDS,
|
|
96
|
+
outside_ticks=False, done=lambda s: "Microsoft account connected")
|
|
97
|
+
if current.get("state") == "connecting" or (site and site != current.get("property")):
|
|
98
|
+
current = _choose_site(ctx, client, site)
|
|
99
|
+
return current
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@click.group()
|
|
103
|
+
def bing() -> None:
|
|
104
|
+
"""Bing Webmaster Tools: its status, sites and search numbers."""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@bing.command("status")
|
|
108
|
+
@common_options
|
|
109
|
+
@command_runner(human=describe_status)
|
|
110
|
+
def status(ctx: click.Context, org_flag: str | None) -> dict[str, Any]:
|
|
111
|
+
"""Whether Bing is connected, and how fresh its numbers are."""
|
|
112
|
+
return _read_status(open_client(ctx, org_flag))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _describe_sites(listed: dict[str, Any]) -> str:
|
|
116
|
+
suggested = listed.get("suggested_site")
|
|
117
|
+
sites = [entry["site_url"] for entry in listed.get("sites") or []]
|
|
118
|
+
ordered = sorted(sites, key=lambda url: url != suggested)
|
|
119
|
+
return "\n".join(f"{url} (suggested)" if url == suggested else url for url in ordered) or "No verified sites."
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@bing.command("sites")
|
|
123
|
+
@common_options
|
|
124
|
+
@command_runner(human=_describe_sites)
|
|
125
|
+
def sites(ctx: click.Context, org_flag: str | None) -> dict[str, Any]:
|
|
126
|
+
"""The sites the connected Microsoft account has verified."""
|
|
127
|
+
listed: dict[str, Any] = _call(open_client(ctx, org_flag), BING_SITES)
|
|
128
|
+
return listed
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@bing.command("use")
|
|
132
|
+
@common_options
|
|
133
|
+
@command_runner(human=describe_status)
|
|
134
|
+
@click.argument("site", required=False)
|
|
135
|
+
@accepted_yes_option
|
|
136
|
+
@dry_run_option
|
|
137
|
+
def use(ctx: click.Context, org_flag: str | None, site: str | None) -> dict[str, Any]:
|
|
138
|
+
"""Choose which verified site is this company's."""
|
|
139
|
+
return _choose_site(ctx, open_client(ctx, org_flag), site)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _describe_performance(data: dict[str, Any]) -> str:
|
|
143
|
+
period = data.get("period") or {}
|
|
144
|
+
heading = f"{data.get('property')}, {period.get('start')} to {period.get('end')}"
|
|
145
|
+
totals = data.get("totals")
|
|
146
|
+
if not totals:
|
|
147
|
+
return f"{heading}\nNo Bing numbers stored for this period yet."
|
|
148
|
+
if not totals.get("impressions"): # days Bing reported empty: no search data, never a zero
|
|
149
|
+
return f"{heading}\nBing has no search data for this site in this period yet."
|
|
150
|
+
lines = [heading, f"{totals_sentence(totals)}. Data through {data.get('data_through')}."]
|
|
151
|
+
ranked = ranked_lines("Top queries", data.get("queries") or []) + ranked_lines("Top pages", data.get("pages") or [])
|
|
152
|
+
if ranked:
|
|
153
|
+
lines += ["Weekly figures below, separate from the daily totals above.", *ranked]
|
|
154
|
+
return "\n".join(lines)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@bing.command("data")
|
|
158
|
+
@common_options
|
|
159
|
+
@command_runner(human=_describe_performance)
|
|
160
|
+
@click.option("--days", type=click.IntRange(7, 90), default=28, show_default=True, help="How many days back to read.")
|
|
161
|
+
def data(ctx: click.Context, org_flag: str | None, days: int) -> dict[str, Any]:
|
|
162
|
+
"""The stored search numbers: totals, top queries and top pages."""
|
|
163
|
+
answer: dict[str, Any] = _call(open_client(ctx, org_flag), BING_PERFORMANCE, params={"window_days": days})
|
|
164
|
+
return answer
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Publication cadence, owner consent and a read-only two-week plan."""
|
|
2
|
+
|
|
3
|
+
from datetime import time
|
|
4
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from recala_cli.ids import resolve_person_id
|
|
9
|
+
from recala_cli.commands.faithful_translations import faithful_translations
|
|
10
|
+
from recala_cli.commands.reads import describe_slot_state
|
|
11
|
+
from recala_cli.endpoints import (
|
|
12
|
+
AUTOAPPROVE_SET,
|
|
13
|
+
AUTOAPPROVE_SHOW,
|
|
14
|
+
CADENCE_SET,
|
|
15
|
+
CADENCE_SHOW,
|
|
16
|
+
PUBLICATION_PLAN,
|
|
17
|
+
)
|
|
18
|
+
from recala_cli.runtime import command_runner, common_options, confirm, open_client, optional_read, accepted_yes_option
|
|
19
|
+
from recala_cli.labels import resource_label
|
|
20
|
+
from recala_cli.errors import CliError
|
|
21
|
+
from recala_cli.organizations import organization_label
|
|
22
|
+
|
|
23
|
+
DAYS = ("mon", "tue", "wed", "thu", "fri", "sat", "sun")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
CADENCE_FIELDS = ("days", "local_time", "timezone", "autopilot_enabled", "write_ahead_hours", "daily_generation_cap")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def current_cadence(client):
|
|
30
|
+
"""The saved settings a change leaves alone, so a preview describes the result and not just the request."""
|
|
31
|
+
saved = (optional_read(client, CADENCE_SHOW) or {}).get("cadence") or {}
|
|
32
|
+
return {field: saved[field] for field in CADENCE_FIELDS if saved.get(field) is not None}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def show_cadence(result):
|
|
36
|
+
data = result.get("cadence") or {}
|
|
37
|
+
if not data:
|
|
38
|
+
return "No publication cadence configured."
|
|
39
|
+
days = ",".join(DAYS[day] for day in data["days"])
|
|
40
|
+
consent = "on" if data.get("autopilot_enabled") else "off"
|
|
41
|
+
prefix = "Dry run — " if result.get("dry_run") else ""
|
|
42
|
+
return f"{prefix}{days} at {data['local_time']} {data['timezone']} · autopilot={consent}"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def describe_company_cadence(ctx, result):
|
|
46
|
+
company = ctx.meta.get("cadence_company")
|
|
47
|
+
return (f"{company}\n" if company else "") + show_cadence(result)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@click.group()
|
|
51
|
+
def cadence():
|
|
52
|
+
"""Show or set publication days and time.
|
|
53
|
+
|
|
54
|
+
Changing them needs an owner session."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@cadence.command("show")
|
|
58
|
+
@common_options
|
|
59
|
+
@command_runner(contextual_human=describe_company_cadence)
|
|
60
|
+
def show(ctx, org_flag):
|
|
61
|
+
"""Show the publication days, time and autopilot setting."""
|
|
62
|
+
client = open_client(ctx, org_flag)
|
|
63
|
+
ctx.meta["cadence_company"] = organization_label(ctx, client)
|
|
64
|
+
return client.call(CADENCE_SHOW)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@cadence.command("set")
|
|
68
|
+
@common_options
|
|
69
|
+
@command_runner(contextual_human=describe_company_cadence)
|
|
70
|
+
@click.option("--days", required=True, help="Comma-separated weekdays: mon,wed,fri.")
|
|
71
|
+
@click.option("--time", "local_time", required=True)
|
|
72
|
+
@click.option("--tz", "timezone", required=True)
|
|
73
|
+
@click.option(
|
|
74
|
+
"--autopilot/--no-autopilot",
|
|
75
|
+
default=None,
|
|
76
|
+
help="Opt into topic assignment and generation; preserves current consent if omitted.",
|
|
77
|
+
)
|
|
78
|
+
@click.option("--ahead-hours", type=click.IntRange(36, 336), default=None)
|
|
79
|
+
@click.option("--daily-cap", type=click.IntRange(1, 10), default=None)
|
|
80
|
+
@click.option(
|
|
81
|
+
"--dry-run", is_flag=True, help="Validate and print proposed settings without saving."
|
|
82
|
+
)
|
|
83
|
+
@accepted_yes_option
|
|
84
|
+
def set_cadence(
|
|
85
|
+
ctx, org_flag, days, local_time, timezone, autopilot, ahead_hours, daily_cap, dry_run
|
|
86
|
+
):
|
|
87
|
+
"""Set publication days and time; --dry-run previews without saving."""
|
|
88
|
+
try:
|
|
89
|
+
parsed_days = sorted({DAYS.index(day.strip().lower()) for day in days.split(",")})
|
|
90
|
+
clock = time.fromisoformat(local_time)
|
|
91
|
+
if clock.tzinfo or clock.second or clock.microsecond:
|
|
92
|
+
raise ValueError()
|
|
93
|
+
ZoneInfo(timezone)
|
|
94
|
+
except (ValueError, ZoneInfoNotFoundError) as error:
|
|
95
|
+
raise click.BadParameter("Use mon..sun, HH:MM and a valid IANA timezone.") from error
|
|
96
|
+
body = {"days": parsed_days, "local_time": clock.strftime("%H:%M"), "timezone": timezone}
|
|
97
|
+
for key, value in (
|
|
98
|
+
("autopilot_enabled", autopilot),
|
|
99
|
+
("write_ahead_hours", ahead_hours),
|
|
100
|
+
("daily_generation_cap", daily_cap),
|
|
101
|
+
):
|
|
102
|
+
if value is not None:
|
|
103
|
+
body[key] = value
|
|
104
|
+
client = open_client(ctx, org_flag)
|
|
105
|
+
if dry_run:
|
|
106
|
+
return {"dry_run": True, "cadence": {**current_cadence(client), **body}}
|
|
107
|
+
ctx.meta["cadence_company"] = organization_label(ctx, client)
|
|
108
|
+
policy = {}
|
|
109
|
+
if not ctx.obj.get("as_json"):
|
|
110
|
+
try:
|
|
111
|
+
policy = optional_read(client, AUTOAPPROVE_SHOW) or {}
|
|
112
|
+
except CliError:
|
|
113
|
+
pass # A missing display name must not bypass or prevent consent.
|
|
114
|
+
|
|
115
|
+
describe_signer(ctx, client, policy.get("default_author_person_id"))
|
|
116
|
+
signer = ctx.meta.get("author_label") or "the configured signer"
|
|
117
|
+
confirm(ctx, client, f"Save publication cadence ({body['timezone']}, {body['local_time']}) with {signer}?"
|
|
118
|
+
+ (" Enable autopilot?" if autopilot else ""))
|
|
119
|
+
return client.call(CADENCE_SET, json=body)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@click.group()
|
|
123
|
+
def autoapprove():
|
|
124
|
+
"""Automatic acceptance of passing drafts.
|
|
125
|
+
|
|
126
|
+
Changing it needs an owner session."""
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
autoapprove.add_command(faithful_translations)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def show_policy(ctx, data):
|
|
133
|
+
consent = "on" if data.get("auto_approve_passing") or data.get("enabled") else "off"
|
|
134
|
+
author = ctx.meta.get("author_label") or data.get("default_author_person_id") or "not configured"
|
|
135
|
+
prefix = "Dry run — " if data.get("dry_run") else ""
|
|
136
|
+
return f"{prefix}Auto-approve: {consent} · author: {author}"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@autoapprove.command("show")
|
|
140
|
+
@common_options
|
|
141
|
+
@command_runner(contextual_human=show_policy)
|
|
142
|
+
def show_approval(ctx, org_flag):
|
|
143
|
+
"""Show whether passing drafts are auto-approved."""
|
|
144
|
+
client = open_client(ctx, org_flag)
|
|
145
|
+
policy = client.call(AUTOAPPROVE_SHOW)
|
|
146
|
+
describe_signer(ctx, client, policy.get("default_author_person_id"))
|
|
147
|
+
return policy
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def describe_signer(ctx, client, author):
|
|
151
|
+
if author:
|
|
152
|
+
ctx.meta["author_label"] = resource_label(ctx, client, "person", author, result_subject=False)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def change_approval(ctx, org_flag, enabled, author, dry_run):
|
|
156
|
+
client = open_client(ctx, org_flag)
|
|
157
|
+
author = resolve_person_id(client, author) if author else author
|
|
158
|
+
policy = client.call(AUTOAPPROVE_SHOW)
|
|
159
|
+
body = {
|
|
160
|
+
"enabled": enabled,
|
|
161
|
+
"default_author_person_id": author or policy.get("default_author_person_id"),
|
|
162
|
+
}
|
|
163
|
+
describe_signer(ctx, client, body["default_author_person_id"])
|
|
164
|
+
if dry_run:
|
|
165
|
+
return {"dry_run": True, **body}
|
|
166
|
+
signer = ctx.meta.get("author_label") or "the configured signer"
|
|
167
|
+
confirm(ctx, client, f"Turn auto-approve {'on' if enabled else 'off'}, signed by {signer}?")
|
|
168
|
+
return client.call(AUTOAPPROVE_SET, json=body)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@autoapprove.command("on")
|
|
172
|
+
@common_options
|
|
173
|
+
@command_runner(contextual_human=show_policy)
|
|
174
|
+
@click.option(
|
|
175
|
+
"--author", default=None, help="Active signing person's id; otherwise keep the current author."
|
|
176
|
+
)
|
|
177
|
+
@click.option("--dry-run", is_flag=True)
|
|
178
|
+
@accepted_yes_option
|
|
179
|
+
def on(ctx, org_flag, author, dry_run):
|
|
180
|
+
"""Turn on automatic approval for this organization."""
|
|
181
|
+
return change_approval(ctx, org_flag, True, author, dry_run)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@autoapprove.command("off")
|
|
185
|
+
@common_options
|
|
186
|
+
@command_runner(contextual_human=show_policy)
|
|
187
|
+
@click.option("--dry-run", is_flag=True)
|
|
188
|
+
@accepted_yes_option
|
|
189
|
+
def off(ctx, org_flag, dry_run):
|
|
190
|
+
"""Turn off automatic approval for this organization."""
|
|
191
|
+
return change_approval(ctx, org_flag, False, None, dry_run)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def describe_plan(result):
|
|
195
|
+
from recala_cli.views import moment_in
|
|
196
|
+
cadence = result.get("cadence") or {}
|
|
197
|
+
zone = cadence.get("timezone") or result.get("timezone") or "UTC"
|
|
198
|
+
lines = ["Dry run — no changes"]
|
|
199
|
+
for slot in result.get("slots") or []:
|
|
200
|
+
lines.append(
|
|
201
|
+
f"{moment_in(slot.get('scheduled_at'), zone)} "
|
|
202
|
+
f"{slot.get('topic') or slot.get('title') or '(no topic)'} · "
|
|
203
|
+
f"{describe_slot_state(slot, cadence)}"
|
|
204
|
+
)
|
|
205
|
+
lines.extend(f"{topic['topic']} -> skipped -> {topic['reason']}" for topic in result.get("skipped_topics", []))
|
|
206
|
+
return "\n".join(lines)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@click.command()
|
|
210
|
+
@common_options
|
|
211
|
+
@command_runner(human=describe_plan)
|
|
212
|
+
@click.option("--weeks", type=click.IntRange(1, 2), default=2, show_default=True)
|
|
213
|
+
def plan(ctx, org_flag, weeks):
|
|
214
|
+
"""Preview each slot, topic and state.
|
|
215
|
+
|
|
216
|
+
Never generates, approves or publishes."""
|
|
217
|
+
return open_client(ctx, org_flag).call(PUBLICATION_PLAN, params={"weeks": weeks})
|